From 2bdde375355bfb7f85b455e2910b30234e550762 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 12 Sep 2019 19:58:42 +0300 Subject: [PATCH] Implement DataSource service, Data aggregator, Flot widget. --- ui-ngx/angular.json | 9 + ui-ngx/package-lock.json | 28 + ui-ngx/package.json | 5 + ui-ngx/src/app/core/api/data-aggregator.ts | 332 +++++ .../app/core/api/datasource-subcription.ts | 666 ++++++++++ ui-ngx/src/app/core/api/datasource.service.ts | 71 +- ui-ngx/src/app/core/api/widget-api.models.ts | 19 +- .../src/app/core/api/widget-subscription.ts | 323 ++++- ui-ngx/src/app/core/services/utils.service.ts | 31 + ui-ngx/src/app/core/utils.ts | 36 +- .../core/ws/telemetry-websocket.service.ts | 7 +- .../dashboard/dashboard.component.ts | 10 +- .../entity/entities-table.component.ts | 4 +- .../widget/dynamic-widget.component.ts | 4 +- .../components/widget/legend.component.scss | 4 + .../components/widget/legend.component.ts | 6 +- .../widget/lib/flot-widget.models.ts | 592 +++++++++ .../home/components/widget/lib/flot-widget.ts | 1074 +++++++++++++++++ .../widget/widget-component.service.ts | 3 + .../components/widget/widget.component.html | 14 +- .../components/widget/widget.component.ts | 46 +- .../home/models/dashboard-component.models.ts | 3 +- .../home/models/widget-component.models.ts | 6 +- .../models/telemetry/telemetry.models.ts | 13 +- .../src/app/shared/models/time/time.models.ts | 84 +- ui-ngx/src/app/shared/models/widget.models.ts | 27 +- ui-ngx/src/styles.scss | 4 + ui-ngx/src/tsconfig.app.json | 2 +- ui-ngx/src/typings/jquery.flot.typings.d.ts | 128 ++ .../jquery.typings.d.ts} | 0 ui-ngx/tsconfig.json | 3 +- 31 files changed, 3500 insertions(+), 54 deletions(-) create mode 100644 ui-ngx/src/app/core/api/data-aggregator.ts create mode 100644 ui-ngx/src/app/core/api/datasource-subcription.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts create mode 100644 ui-ngx/src/typings/jquery.flot.typings.d.ts rename ui-ngx/src/{typings.d.ts => typings/jquery.typings.d.ts} (100%) diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index 629a0fd8df..fd0555c661 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -38,6 +38,15 @@ "node_modules/javascript-detect-element-resize/detect-element-resize.js", "node_modules/jquery/dist/jquery.min.js", "node_modules/jquery.terminal/js/jquery.terminal.min.js", + "node_modules/flot/lib/jquery.colorhelpers.js", + "node_modules/flot/src/jquery.flot.js", + "node_modules/flot/src/plugins/jquery.flot.time.js", + "node_modules/flot/src/plugins/jquery.flot.selection.js", + "node_modules/flot/src/plugins/jquery.flot.pie.js", + "node_modules/flot/src/plugins/jquery.flot.crosshair.js", + "node_modules/flot/src/plugins/jquery.flot.stack.js", + "node_modules/flot.curvedlines/curvedLines.js", + "node_modules/tinycolor2/dist/tinycolor-min.js", "node_modules/ace-builds/src-min/ace.js", "node_modules/ace-builds/src-min/ext-language_tools.js", "node_modules/ace-builds/src-min/ext-searchbox.js", diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index a5f864c71c..5f93537aff 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1126,6 +1126,15 @@ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", "dev": true }, + "@types/flot": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/@types/flot/-/flot-0.0.31.tgz", + "integrity": "sha512-X+RcMQCqPlQo8zPT6cUFTd/PoYBShMQlHUeOXf05jWlfYnvLuRmluB9z+2EsOKFgUzqzZve5brx+gnFxBaHEUw==", + "dev": true, + "requires": { + "@types/jquery": "*" + } + }, "@types/glob": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", @@ -1196,6 +1205,12 @@ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", "dev": true }, + "@types/tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw==", + "dev": true + }, "@types/webpack-sources": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.5.tgz", @@ -4412,6 +4427,14 @@ "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", "dev": true }, + "flot": { + "version": "git://github.com/thingsboard/flot.git#6e1a37095868f174d31d5c627c3659b70f9b92dd", + "from": "git://github.com/thingsboard/flot.git#0.9-work" + }, + "flot.curvedlines": { + "version": "git://github.com/MichaelZinsmaier/CurvedLines.git#22ed1fc2a6ccafc816c2d07b36027cc123825c4b", + "from": "git://github.com/MichaelZinsmaier/CurvedLines.git#master" + }, "flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -7358,6 +7381,11 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index b76d0686c0..12c92c1192 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -37,6 +37,8 @@ "compass-sass-mixins": "^0.12.7", "core-js": "^3.1.4", "deep-equal": "^1.0.1", + "flot": "git://github.com/thingsboard/flot.git#0.9-work", + "flot.curvedlines": "git://github.com/MichaelZinsmaier/CurvedLines.git#master", "font-awesome": "^4.7.0", "hammerjs": "^2.0.8", "javascript-detect-element-resize": "^0.5.3", @@ -44,6 +46,7 @@ "jquery.terminal": "^2.8.0", "material-design-icons": "^3.0.1", "messageformat": "^2.3.0", + "moment": "^2.24.0", "ngx-clipboard": "^12.2.0", "ngx-translate-messageformat-compiler": "^4.5.0", "rxjs": "~6.5.2", @@ -59,10 +62,12 @@ "@angular/cli": "~8.2.0", "@angular/compiler-cli": "~8.2.0", "@angular/language-service": "~8.2.0", + "@types/flot": "0.0.31", "@types/jasmine": "~3.4.0", "@types/jasminewd2": "~2.0.6", "@types/jquery": "^3.3.31", "@types/node": "~10.14.15", + "@types/tinycolor2": "^1.4.2", "codelyzer": "~5.1.0", "compression-webpack-plugin": "^3.0.0", "directory-tree": "^2.2.3", diff --git a/ui-ngx/src/app/core/api/data-aggregator.ts b/ui-ngx/src/app/core/api/data-aggregator.ts new file mode 100644 index 0000000000..bea5709055 --- /dev/null +++ b/ui-ngx/src/app/core/api/data-aggregator.ts @@ -0,0 +1,332 @@ +/// +/// Copyright © 2016-2019 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 { SubscriptionData, SubscriptionUpdateMsg, SubscriptionDataHolder } from '@app/shared/models/telemetry/telemetry.models'; +import { AggregationType } from '@shared/models/time/time.models'; +import { UtilsService } from '@core/services/utils.service'; +import Timeout = NodeJS.Timeout; +import { deepClone } from '@core/utils'; + +export declare type onAggregatedData = (data: SubscriptionData, detectChanges: boolean) => void; + +interface AggData { + count: number; + sum: number; + aggValue: any; +} + +interface AggregationMap { + [key: string]: Map; +} + +declare type AggFunction = (aggData: AggData, value?: any) => void; + +const avg: AggFunction = (aggData: AggData, value?: any) => { + aggData.count++; + aggData.sum += value; + aggData.aggValue = aggData.sum / aggData.count; +}; + +const min: AggFunction = (aggData: AggData, value?: any) => { + aggData.aggValue = Math.min(aggData.aggValue, value); +}; + +const max: AggFunction = (aggData: AggData, value?: any) => { + aggData.aggValue = Math.max(aggData.aggValue, value); +}; + +const sum: AggFunction = (aggData: AggData, value?: any) => { + aggData.aggValue = aggData.aggValue + value; +}; + +const count: AggFunction = (aggData: AggData) => { + aggData.count++; + aggData.aggValue = aggData.count; +}; + +const none: AggFunction = (aggData: AggData, value?: any) => { + aggData.aggValue = value; +}; + +export class DataAggregator { + + private dataBuffer: SubscriptionData = {}; + private data: SubscriptionData; + private lastPrevKvPairData: {[key: string]: [number, any]}; + + private aggregationMap: AggregationMap; + + private dataReceived = false; + private resetPending = false; + + private noAggregation = this.aggregationType === AggregationType.NONE; + private aggregationTimeout = Math.max(this.interval, 1000); + private aggFunction: AggFunction; + + private intervalTimeoutHandle: Timeout; + private intervalScheduledTime: number; + + private endTs: number; + private elapsed: number; + + constructor(private onDataCb: onAggregatedData, + private tsKeyNames: string[], + private startTs: number, + private limit: number, + private aggregationType: AggregationType, + private timeWindow: number, + private interval: number, + private stateData: boolean, + private utils: UtilsService) { + this.tsKeyNames.forEach((key) => { + this.dataBuffer[key] = []; + }); + if (this.stateData) { + this.lastPrevKvPairData = {}; + } + switch (this.aggregationType) { + case AggregationType.MIN: + this.aggFunction = min; + break; + case AggregationType.MAX: + this.aggFunction = max; + break; + case AggregationType.AVG: + this.aggFunction = avg; + break; + case AggregationType.SUM: + this.aggFunction = sum; + break; + case AggregationType.COUNT: + this.aggFunction = count; + break; + case AggregationType.NONE: + this.aggFunction = none; + break; + default: + this.aggFunction = avg; + } + } + + public reset(startTs: number, timeWindow: number, interval: number) { + if (this.intervalTimeoutHandle) { + clearTimeout(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + this.intervalScheduledTime = this.utils.currentPerfTime(); + this.startTs = startTs; + this.timeWindow = timeWindow; + this.interval = interval; + this.endTs = this.startTs + this.timeWindow; + this.elapsed = 0; + this.aggregationTimeout = Math.max(this.interval, 1000); + this.resetPending = true; + this.intervalTimeoutHandle = setTimeout(this.onInterval.bind(this), this.aggregationTimeout); + } + + public destroy() { + if (this.intervalTimeoutHandle) { + clearTimeout(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + this.aggregationMap = null; + } + + public onData(data: SubscriptionDataHolder, update: boolean, history: boolean, detectChanges: boolean) { + if (!this.dataReceived || this.resetPending) { + let updateIntervalScheduledTime = true; + if (!this.dataReceived) { + this.elapsed = 0; + this.dataReceived = true; + this.endTs = this.startTs + this.timeWindow; + } + if (this.resetPending) { + this.resetPending = false; + updateIntervalScheduledTime = false; + } + if (update) { + this.aggregationMap = {}; + this.updateAggregatedData(data.data); + } else { + this.aggregationMap = this.processAggregatedData(data.data); + } + if (updateIntervalScheduledTime) { + this.intervalScheduledTime = this.utils.currentPerfTime(); + } + this.onInterval(history, detectChanges); + } else { + this.updateAggregatedData(data.data); + if (history) { + this.intervalScheduledTime = this.utils.currentPerfTime(); + this.onInterval(history, detectChanges); + } + } + } + + private onInterval(history?: boolean, detectChanges?: boolean) { + const now = this.utils.currentPerfTime(); + this.elapsed += now - this.intervalScheduledTime; + this.intervalScheduledTime = now; + if (this.intervalTimeoutHandle) { + clearTimeout(this.intervalTimeoutHandle); + this.intervalTimeoutHandle = null; + } + if (!history) { + const delta = Math.floor(this.elapsed / this.interval); + if (delta || !this.data) { + this.startTs += delta * this.interval; + this.endTs += delta * this.interval; + this.data = this.updateData(); + this.elapsed = this.elapsed - delta * this.interval; + } + } else { + this.data = this.updateData(); + } + if (this.onDataCb) { + this.onDataCb(this.data, detectChanges); + } + if (!history) { + this.intervalTimeoutHandle = setTimeout(this.onInterval.bind(this), this.aggregationTimeout); + } + } + + private updateData(): SubscriptionData { + this.tsKeyNames.forEach((key) => { + this.dataBuffer[key] = []; + }); + for (const key of Object.keys(this.aggregationMap)) { + const aggKeyData = this.aggregationMap[key]; + let keyData = this.dataBuffer[key]; + aggKeyData.forEach((aggData, aggTimestamp) => { + if (aggTimestamp <= this.startTs) { + if (this.stateData && + (!this.lastPrevKvPairData[key] || this.lastPrevKvPairData[key][0] < aggTimestamp)) { + this.lastPrevKvPairData[key] = [aggTimestamp, aggData.aggValue]; + } + aggKeyData.delete(aggTimestamp); + } else if (aggTimestamp <= this.endTs) { + const kvPair: [number, any] = [aggTimestamp, aggData.aggValue]; + keyData.push(kvPair); + } + }); + keyData.sort((set1, set2) => set1[0] - set2[0]); + if (this.stateData) { + this.updateStateBounds(keyData, deepClone(this.lastPrevKvPairData[key])); + } + if (keyData.length > this.limit) { + keyData = keyData.slice(keyData.length - this.limit); + } + this.dataBuffer[key] = keyData; + } + return this.dataBuffer; + } + + private updateStateBounds(keyData: [number, any][], lastPrevKvPair: [number, any]) { + if (lastPrevKvPair) { + lastPrevKvPair[0] = this.startTs; + } + let firstKvPair; + if (!keyData.length) { + if (lastPrevKvPair) { + firstKvPair = lastPrevKvPair; + keyData.push(firstKvPair); + } + } else { + firstKvPair = keyData[0]; + } + if (firstKvPair && firstKvPair[0] > this.startTs) { + if (lastPrevKvPair) { + keyData.unshift(lastPrevKvPair); + } + } + if (keyData.length) { + let lastKvPair = keyData[keyData.length - 1]; + if (lastKvPair[0] < this.endTs) { + lastKvPair = deepClone(lastKvPair); + lastKvPair[0] = this.endTs; + keyData.push(lastKvPair); + } + } + } + + private processAggregatedData(data: SubscriptionData): AggregationMap { + const isCount = this.aggregationType === AggregationType.COUNT; + const aggregationMap: AggregationMap = {}; + for (const key of Object.keys(data)) { + let aggKeyData = aggregationMap[key]; + if (!aggKeyData) { + aggKeyData = new Map(); + aggregationMap[key] = aggKeyData; + } + const keyData = data[key]; + keyData.forEach((kvPair) => { + const timestamp = kvPair[0]; + const value = this.convertValue(kvPair[1]); + const aggKey = timestamp; + const aggData = { + count: isCount ? value : 1, + sum: value, + aggValue: value + }; + aggKeyData.set(aggKey, aggData); + }); + } + return aggregationMap; + } + + private updateAggregatedData(data: SubscriptionData) { + const isCount = this.aggregationType === AggregationType.COUNT; + for (const key of Object.keys(data)) { + let aggKeyData = this.aggregationMap[key]; + if (!aggKeyData) { + aggKeyData = new Map(); + this.aggregationMap[key] = aggKeyData; + } + const keyData = data[key]; + keyData.forEach((kvPair) => { + const timestamp = kvPair[0]; + const value = this.convertValue(kvPair[1]); + const aggTimestamp = this.noAggregation ? timestamp : (this.startTs + + Math.floor((timestamp - this.startTs) / this.interval) * this.interval + this.interval / 2); + let aggData = aggKeyData.get(aggTimestamp); + if (!aggData) { + aggData = { + count: 1, + sum: value, + aggValue: isCount ? 1 : value + }; + aggKeyData.set(aggTimestamp, aggData); + } else { + this.aggFunction(aggData, value); + } + }); + } + } + + private isNumeric(val: any): boolean { + return (val - parseFloat( val ) + 1) >= 0; + } + + private convertValue(val: string): any { + if (!this.noAggregation || val && this.isNumeric(val)) { + return Number(val); + } else { + return val; + } + } + +} + diff --git a/ui-ngx/src/app/core/api/datasource-subcription.ts b/ui-ngx/src/app/core/api/datasource-subcription.ts new file mode 100644 index 0000000000..982215a50b --- /dev/null +++ b/ui-ngx/src/app/core/api/datasource-subcription.ts @@ -0,0 +1,666 @@ +/// +/// Copyright © 2016-2019 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 { DataSet, DataSetHolder, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { + AttributesSubscriptionCmd, + DataKeyType, + GetHistoryCmd, + SubscriptionData, + SubscriptionDataHolder, + SubscriptionUpdateMsg, + TelemetryService, + TelemetrySubscriber, + TimeseriesSubscriptionCmd +} from '@shared/models/telemetry/telemetry.models'; +import { DatasourceListener } from './datasource.service'; +import { AggregationType, SubscriptionTimewindow, YEAR } from '@shared/models/time/time.models'; +import { deepClone, isDefined, isObject, isDefinedAndNotNull } from '@core/utils'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataAggregator } from '@core/api/data-aggregator'; +import Timeout = NodeJS.Timeout; + +declare type DataKeyFunction = (time: number, prevValue: any) => any; + +declare type DataKeyPostFunction = (time: number, value: any, prevValue: any, timePrev: number, prevOrigValue: any) => any; + +export interface SubscriptionDataKey { + name: string; + type: DataKeyType; + funcBody: string; + func?: DataKeyFunction; + postFuncBody: string; + postFunc?: DataKeyPostFunction; + index?: number; + key?: string; + lastUpdateTime?: number; +} + +export interface DatasourceSubscriptionOptions { + datasourceType: DatasourceType; + dataKeys: Array; + type: widgetType; + entityType?: EntityType; + entityId?: string; + subscriptionTimewindow?: SubscriptionTimewindow; +} + +export class DatasourceSubscription { + + private listeners: Array = []; + private datasourceType: DatasourceType = this.datasourceSubscriptionOptions.datasourceType; + + private history = this.datasourceSubscriptionOptions.subscriptionTimewindow && + isObject(this.datasourceSubscriptionOptions.subscriptionTimewindow.fixedWindow); + + private realtime = this.datasourceSubscriptionOptions.subscriptionTimewindow && + isDefinedAndNotNull(this.datasourceSubscriptionOptions.subscriptionTimewindow.realtimeWindowMs); + + private subscribers = new Array(); + + private dataAggregator: DataAggregator; + + private dataKeys: {[key: string]: Array | SubscriptionDataKey} = {}; + private datasourceData: {[key: string]: DataSetHolder} = {}; + private datasourceOrigData: {[key: string]: DataSetHolder} = {}; + + private frequency: number; + private tickScheduledTime = 0; + private tickElapsed = 0; + private timer: Timeout; + + constructor(private datasourceSubscriptionOptions: DatasourceSubscriptionOptions, + private telemetryService: TelemetryService, + private utils: UtilsService) { + this.initializeSubscription(); + } + + private initializeSubscription() { + for (let i = 0; i < this.datasourceSubscriptionOptions.dataKeys.length; i++) { + const dataKey = deepClone(this.datasourceSubscriptionOptions.dataKeys[i]); + dataKey.index = i; + if (this.datasourceType === DatasourceType.function) { + if (!dataKey.func) { + dataKey.func = new Function('time', 'prevValue', dataKey.funcBody) as DataKeyFunction; + } + } else { + if (dataKey.postFuncBody && !dataKey.postFunc) { + dataKey.postFunc = new Function('time', 'value', 'prevValue', 'timePrev', 'prevOrigValue', + dataKey.postFuncBody) as DataKeyPostFunction; + } + } + let key: string; + if (this.datasourceType === DatasourceType.entity || this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + if (this.datasourceType === DatasourceType.function) { + key = `${dataKey.name}_${dataKey.index}_${dataKey.type}`; + } else { + key = `${dataKey.name}_${dataKey.type}`; + } + let dataKeysList = this.dataKeys[key] as Array; + if (!dataKeysList) { + dataKeysList = []; + this.dataKeys[key] = dataKeysList; + } + const index = dataKeysList.push(dataKey) - 1; + this.datasourceData[key + '_' + index] = { + data: [] + }; + } else { + key = String(this.utils.objectHashCode(dataKey)); + this.datasourceData[key] = { + data: [] + }; + this.dataKeys[key] = dataKey; + } + dataKey.key = key; + } + this.datasourceOrigData = deepClone(this.datasourceData); + if (this.datasourceType === DatasourceType.function) { + this.frequency = 1000; + if (this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + this.frequency = Math.min(this.datasourceSubscriptionOptions.subscriptionTimewindow.aggregation.interval, 5000); + } + } + } + + public addListener(listener: DatasourceListener) { + this.listeners.push(listener); + if (this.history) { + this.start(); + } + } + + public hasListeners(): boolean { + return this.listeners.length > 0; + } + + public removeListener(listener: DatasourceListener) { + this.listeners.splice(this.listeners.indexOf(listener), 1); + } + + public syncListener(listener: DatasourceListener) { + let key: string; + let dataKey: SubscriptionDataKey; + if (this.datasourceType === DatasourceType.entity || this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + for (key of Object.keys(this.dataKeys)) { + const dataKeysList = this.dataKeys[key] as Array; + for (let i = 0; i < dataKeysList.length; i++) { + dataKey = dataKeysList[i]; + const datasourceKey = `${key}_${i}`; + listener.dataUpdated(this.datasourceData[datasourceKey], + listener.datasourceIndex, + dataKey.index, false); + } + } + } else { + for (key of Object.keys(this.dataKeys)) { + dataKey = this.dataKeys[key] as SubscriptionDataKey; + listener.dataUpdated(this.datasourceData[key], + listener.datasourceIndex, + dataKey.index, false); + } + } + } + + public unsubscribe() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.datasourceType === DatasourceType.entity) { + this.subscribers.forEach( + (subscriber) => { + subscriber.unsubscribe(); + } + ); + this.subscribers.length = 0; + } + if (this.dataAggregator) { + this.dataAggregator.destroy(); + this.dataAggregator = null; + } + } + + public start() { + if (this.history && !this.hasListeners()) { + return; + } + let subsTw = this.datasourceSubscriptionOptions.subscriptionTimewindow; + const tsKeyNames: string[] = []; + const attrKeyNames: string[] = []; + let dataKey: SubscriptionDataKey; + if (this.datasourceType === DatasourceType.entity) { + + let tsKeys = ''; + let attrKeys = ''; + + for (const key of Object.keys(this.dataKeys)) { + const dataKeysList = this.dataKeys[key] as Array; + dataKey = dataKeysList[0]; + if (dataKey.type === DataKeyType.timeseries) { + tsKeyNames.push(dataKey.name); + } else if (dataKey.type === DataKeyType.attribute) { + attrKeyNames.push(dataKey.name); + } + } + tsKeys = tsKeyNames.join(','); + attrKeys = attrKeyNames.join(','); + if (tsKeys.length > 0) { + if (this.history) { + const historyCommand = new GetHistoryCmd(); + historyCommand.entityType = this.datasourceSubscriptionOptions.entityType; + historyCommand.entityId = this.datasourceSubscriptionOptions.entityId; + historyCommand.keys = tsKeys; + historyCommand.startTs = subsTw.fixedWindow.startTimeMs; + historyCommand.endTs = subsTw.fixedWindow.endTimeMs; + historyCommand.interval = subsTw.aggregation.interval; + historyCommand.limit = subsTw.aggregation.limit; + historyCommand.agg = subsTw.aggregation.type; + + const subscriber = new TelemetrySubscriber(this.telemetryService); + subscriber.subscriptionCommands.push(historyCommand); + + let firstStateHistoryCommand: GetHistoryCmd; + if (subsTw.aggregation.stateData) { + firstStateHistoryCommand = this.createFirstStateHistoryCommand(subsTw.fixedWindow.startTimeMs, tsKeys); + subscriber.subscriptionCommands.push(firstStateHistoryCommand); + } + let data: SubscriptionUpdateMsg; + let firstStateData: SubscriptionUpdateMsg; + + subscriber.data$.subscribe( + (subscriptionUpdate) => { + if (subsTw.aggregation.stateData && firstStateHistoryCommand + && firstStateHistoryCommand.cmdId === subscriptionUpdate.subscriptionId) { + if (data) { + this.onStateHistoryData(subscriptionUpdate, data, subsTw.aggregation.limit, + subsTw.fixedWindow.startTimeMs, subsTw.fixedWindow.endTimeMs, + (newData) => { + this.onData(newData.data, DataKeyType.timeseries, true); + } + ); + } else { + firstStateData = data; + } + } else { + if (subsTw.aggregation.stateData) { + if (firstStateData) { + this.onStateHistoryData(firstStateData, subscriptionUpdate, subsTw.aggregation.limit, + subsTw.fixedWindow.startTimeMs, subsTw.fixedWindow.endTimeMs, + (newData) => { + this.onData(newData.data, DataKeyType.timeseries, true); + }); + } else { + data = subscriptionUpdate; + } + } else { + for (const key of Object.keys(data.data)) { + const keyData = data.data[key]; + keyData.sort((set1, set2) => set1[0] - set2[0]); + } + this.onData(data.data, DataKeyType.timeseries, true); + } + } + } + ); + subscriber.subscribe(); + this.subscribers.push(subscriber); + } else { + const subscriptionCommand = new TimeseriesSubscriptionCmd(); + subscriptionCommand.entityType = this.datasourceSubscriptionOptions.entityType; + subscriptionCommand.entityId = this.datasourceSubscriptionOptions.entityId; + subscriptionCommand.keys = tsKeys; + + const subscriber = new TelemetrySubscriber(this.telemetryService); + subscriber.subscriptionCommands.push(subscriptionCommand); + + if (this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + this.updateRealtimeSubscriptionCommand(subscriptionCommand, subsTw); + + let firstStateSubscriptionCommand: GetHistoryCmd; + if (subsTw.aggregation.stateData) { + firstStateSubscriptionCommand = this.createFirstStateHistoryCommand(subsTw.startTs, tsKeys); + subscriber.subscriptionCommands.push(firstStateSubscriptionCommand); + } + this.dataAggregator = this.createRealtimeDataAggregator(subsTw, tsKeyNames, DataKeyType.timeseries); + + let data: SubscriptionUpdateMsg; + let firstStateData: SubscriptionUpdateMsg; + let stateDataReceived: boolean; + + subscriber.data$.subscribe( + (subscriptionUpdate) => { + if (subsTw.aggregation.stateData && + firstStateSubscriptionCommand && firstStateSubscriptionCommand.cmdId === subscriptionUpdate.subscriptionId) { + if (data) { + this.onStateHistoryData(subscriptionUpdate, data, subsTw.aggregation.limit, + subsTw.startTs, subsTw.startTs + subsTw.aggregation.timeWindow, + (newData) => { + this.dataAggregator.onData(newData, false, false, true); + }); + stateDataReceived = true; + } else { + firstStateData = data; + } + } else { + if (subsTw.aggregation.stateData && !stateDataReceived) { + if (firstStateData) { + this.onStateHistoryData(firstStateData, subscriptionUpdate, subsTw.aggregation.limit, + subsTw.startTs, subsTw.startTs + subsTw.aggregation.timeWindow, + (newData) => { + this.dataAggregator.onData(newData, false, false, true); + }); + stateDataReceived = true; + } else { + data = subscriptionUpdate; + } + } else { + this.dataAggregator.onData(subscriptionUpdate, false, false, true); + } + } + } + ); + subscriber.reconnect$.subscribe(() => { + let newSubsTw: SubscriptionTimewindow = null; + this.listeners.forEach((listener) => { + if (!newSubsTw) { + newSubsTw = listener.updateRealtimeSubscription(); + } else { + listener.setRealtimeSubscription(newSubsTw); + } + }); + subsTw = newSubsTw; + firstStateData = null; + data = null; + stateDataReceived = false; + this.updateRealtimeSubscriptionCommand(subscriptionCommand, subsTw); + if (subsTw.aggregation.stateData) { + this.updateFirstStateHistoryCommand(firstStateSubscriptionCommand, subsTw.startTs); + } + this.dataAggregator.reset(newSubsTw.startTs, newSubsTw.aggregation.timeWindow, newSubsTw.aggregation.interval); + }); + } else { + subscriber.data$.subscribe( + (subscriptionUpdate) => { + if (subscriptionUpdate.data) { + this.onData(subscriptionUpdate.data, DataKeyType.timeseries, true); + } + } + ); + } + + subscriber.subscribe(); + this.subscribers.push(subscriber); + + } + } + + if (attrKeys.length) { + const attrsSubscriptionCommand = new AttributesSubscriptionCmd(); + attrsSubscriptionCommand.entityType = this.datasourceSubscriptionOptions.entityType; + attrsSubscriptionCommand.entityId = this.datasourceSubscriptionOptions.entityId; + attrsSubscriptionCommand.keys = attrKeys; + + const subscriber = new TelemetrySubscriber(this.telemetryService); + subscriber.subscriptionCommands.push(attrsSubscriptionCommand); + subscriber.data$.subscribe( + (subscriptionUpdate) => { + if (subscriptionUpdate.data) { + this.onData(subscriptionUpdate.data, DataKeyType.attribute, true); + } + } + ); + + subscriber.subscribe(); + this.subscribers.push(subscriber); + } + } else if (this.datasourceType === DatasourceType.function) { + if (this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + for (const key of Object.keys(this.dataKeys)) { + const dataKeysList = this.dataKeys[key] as Array; + dataKeysList.forEach((subscriptionDataKey) => { + tsKeyNames.push(`${subscriptionDataKey.name}_${subscriptionDataKey.index}`); + }); + } + this.dataAggregator = this.createRealtimeDataAggregator(subsTw, tsKeyNames, DataKeyType.function); + } + this.tickScheduledTime = this.utils.currentPerfTime(); + if (this.history) { + this.onTick(true); + } else { + this.timer = setTimeout(this.onTick.bind(this, true), 0); + } + } + } + + private createFirstStateHistoryCommand(startTs: number, tsKeys: string): GetHistoryCmd { + const command = new GetHistoryCmd(); + command.entityType = this.datasourceSubscriptionOptions.entityType; + command.entityId = this.datasourceSubscriptionOptions.entityId; + command.keys = tsKeys; + command.startTs = startTs - YEAR; + command.endTs = startTs; + command.interval = 1000; + command.limit = 1; + command.agg = AggregationType.NONE; + return command; + } + + private updateFirstStateHistoryCommand(stateHistoryCommand: GetHistoryCmd, startTs: number) { + stateHistoryCommand.startTs = startTs - YEAR; + stateHistoryCommand.endTs = startTs; + } + + private onStateHistoryData(firstStateData: SubscriptionUpdateMsg, data: SubscriptionUpdateMsg, + limit: number, startTs: number, endTs: number, onData: (data: SubscriptionUpdateMsg) => void) { + for (const key of Object.keys(data.data)) { + const keyData = data.data[key]; + keyData.sort((set1, set2) => set1[0] - set2[0]); + if (keyData.length < limit) { + let firstStateKeyData = firstStateData.data[key]; + if (firstStateKeyData.length) { + const firstStateDataTsKv = firstStateKeyData[0]; + firstStateDataTsKv[0] = startTs; + firstStateKeyData = [ + [ startTs, firstStateKeyData[0][1] ] + ]; + keyData.unshift(firstStateDataTsKv); + } + } + if (keyData.length) { + const lastTsKv = deepClone(keyData[keyData.length - 1]); + lastTsKv[0] = endTs; + keyData.push(lastTsKv); + } + } + onData(data); + } + + private createRealtimeDataAggregator(subsTw: SubscriptionTimewindow, + tsKeyNames: Array, dataKeyType: DataKeyType): DataAggregator { + return new DataAggregator( + (data, detectChanges) => { + this.onData(data, dataKeyType, detectChanges); + }, + tsKeyNames, + subsTw.startTs, + subsTw.aggregation.limit, + subsTw.aggregation.type, + subsTw.aggregation.timeWindow, + subsTw.aggregation.interval, + subsTw.aggregation.stateData, + this.utils + ); + } + + private updateRealtimeSubscriptionCommand(subscriptionCommand: TimeseriesSubscriptionCmd, subsTw: SubscriptionTimewindow) { + subscriptionCommand.startTs = subsTw.startTs; + subscriptionCommand.timeWindow = subsTw.aggregation.timeWindow; + subscriptionCommand.interval = subsTw.aggregation.interval; + subscriptionCommand.limit = subsTw.aggregation.limit; + subscriptionCommand.agg = subsTw.aggregation.type; + } + + private generateSeries(dataKey: SubscriptionDataKey, index: number, startTime: number, endTime: number): [number, any][] { + const data: [number, any][] = []; + let prevSeries: [number, any]; + const datasourceDataKey = `${dataKey.key}_${index}`; + const datasourceKeyData = this.datasourceData[datasourceDataKey].data; + if (datasourceKeyData.length > 0) { + prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; + } else { + prevSeries = [0, 0]; + } + for (let time = startTime; time <= endTime && (this.timer || this.history); time += this.frequency) { + const value = dataKey.func(time, prevSeries[1]); + const series: [number, any] = [time, value]; + data.push(series); + prevSeries = series; + } + if (data.length > 0) { + dataKey.lastUpdateTime = data[data.length - 1][0]; + } + return data; + } + + private generateLatest(dataKey: SubscriptionDataKey, detectChanges: boolean) { + let prevSeries: [number, any]; + const datasourceKeyData = this.datasourceData[dataKey.key].data; + if (datasourceKeyData.length > 0) { + prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; + } else { + prevSeries = [0, 0]; + } + const time = Date.now(); + const value = dataKey.func(time, prevSeries[1]); + const series: [number, any] = [time, value]; + this.datasourceData[dataKey.key].data = [series]; + this.listeners.forEach( + (listener) => { + listener.dataUpdated(this.datasourceData[dataKey.key], + listener.datasourceIndex, + dataKey.index, detectChanges); + } + ); + } + + private onTick(detectChanges: boolean) { + const now = this.utils.currentPerfTime(); + this.tickElapsed += now - this.tickScheduledTime; + this.tickScheduledTime = now; + + if (this.timer) { + clearTimeout(this.timer); + } + let key: string; + if (this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + let startTime: number; + let endTime: number; + let delta: number; + const generatedData: SubscriptionDataHolder = { + data: {} + }; + if (!this.history) { + delta = Math.floor(this.tickElapsed / this.frequency); + } + const deltaElapsed = this.history ? this.frequency : delta * this.frequency; + this.tickElapsed = this.tickElapsed - deltaElapsed; + for (key of Object.keys(this.dataKeys)) { + const dataKeyList = this.dataKeys[key] as Array; + for (let index = 0; index < dataKeyList.length && (this.timer || this.history); index ++) { + const dataKey = dataKeyList[index]; + if (!startTime) { + if (this.realtime) { + if (dataKey.lastUpdateTime) { + startTime = dataKey.lastUpdateTime + this.frequency; + endTime = dataKey.lastUpdateTime + deltaElapsed; + } else { + startTime = this.datasourceSubscriptionOptions.subscriptionTimewindow.startTs; + endTime = startTime + this.datasourceSubscriptionOptions.subscriptionTimewindow.realtimeWindowMs + this.frequency; + if (this.datasourceSubscriptionOptions.subscriptionTimewindow.aggregation.type === AggregationType.NONE) { + const time = endTime - this.frequency * this.datasourceSubscriptionOptions.subscriptionTimewindow.aggregation.limit; + startTime = Math.max(time, startTime); + } + } + } else { + startTime = this.datasourceSubscriptionOptions.subscriptionTimewindow.fixedWindow.startTimeMs; + endTime = this.datasourceSubscriptionOptions.subscriptionTimewindow.fixedWindow.endTimeMs; + } + } + const data = this.generateSeries(dataKey, index, startTime, endTime); + generatedData.data[`${dataKey.name}_${dataKey.index}`] = data; + } + } + if (this.dataAggregator) { + this.dataAggregator.onData(generatedData, true, this.history, detectChanges); + } + } else if (this.datasourceSubscriptionOptions.type === widgetType.latest) { + for (key of Object.keys(this.dataKeys)) { + this.generateLatest(this.dataKeys[key] as SubscriptionDataKey, detectChanges); + } + } + + if (!this.history) { + this.timer = setTimeout(this.onTick.bind(this, true), this.frequency); + } + } + + private onData(sourceData: SubscriptionData, type: DataKeyType, detectChanges: boolean) { + for (const keyName of Object.keys(sourceData)) { + const keyData = sourceData[keyName]; + const key = `${keyName}_${type}`; + const dataKeyList = this.dataKeys[key] as Array; + for (let keyIndex = 0; dataKeyList && keyIndex < dataKeyList.length; keyIndex++) { + const datasourceKey = `${key}_${keyIndex}`; + if (this.datasourceData[datasourceKey].data) { + const dataKey = dataKeyList[keyIndex]; + const data: DataSet = []; + let prevSeries: [number, any]; + let prevOrigSeries: [number, any]; + let datasourceKeyData: DataSet; + let datasourceOrigKeyData: DataSet; + let update = false; + if (this.realtime) { + datasourceKeyData = []; + datasourceOrigKeyData = []; + } else { + datasourceKeyData = this.datasourceData[datasourceKey].data; + datasourceOrigKeyData = this.datasourceOrigData[datasourceKey].data; + } + if (datasourceKeyData.length > 0) { + prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; + prevOrigSeries = datasourceOrigKeyData[datasourceOrigKeyData.length - 1]; + } else { + prevSeries = [0, 0]; + prevOrigSeries = [0, 0]; + } + this.datasourceOrigData[datasourceKey].data = []; + if (this.datasourceSubscriptionOptions.type === widgetType.timeseries) { + keyData.forEach((keySeries) => { + let series = keySeries; + const time = series[0]; + this.datasourceOrigData[datasourceKey].data.push(series); + let value = this.convertValue(series[1]); + if (dataKey.postFunc) { + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); + } + prevOrigSeries = series; + series = [time, value]; + data.push(series); + prevSeries = series; + }); + update = true; + } else if (this.datasourceSubscriptionOptions.type === widgetType.latest) { + if (keyData.length > 0) { + let series = keyData[0]; + const time = series[0]; + this.datasourceOrigData[datasourceKey].data.push(series); + let value = this.convertValue(series[1]); + if (dataKey.postFunc) { + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); + } + series = [time, value]; + data.push(series); + } + update = true; + } + if (update) { + this.datasourceData[datasourceKey].data = data; + this.listeners.forEach((listener) => { + listener.dataUpdated(this.datasourceData[datasourceKey], + listener.datasourceIndex, + dataKey.index, detectChanges); + }); + } + } + } + } + } + + private isNumeric(val: any): boolean { + return (val - parseFloat( val ) + 1) >= 0; + } + + private convertValue(val: string): any { + if (val && this.isNumeric(val)) { + return Number(val); + } else { + return val; + } + } + +} diff --git a/ui-ngx/src/app/core/api/datasource.service.ts b/ui-ngx/src/app/core/api/datasource.service.ts index 279466d386..6214f5aeeb 100644 --- a/ui-ngx/src/app/core/api/datasource.service.ts +++ b/ui-ngx/src/app/core/api/datasource.service.ts @@ -18,10 +18,26 @@ import { Injectable } from '@angular/core'; import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; import { UtilsService } from '@core/services/utils.service'; import { EntityType } from '@app/shared/models/entity-type.models'; +import { DataSetHolder, Datasource, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { SubscriptionTimewindow } from '@shared/models/time/time.models'; +import { + DatasourceSubscription, + DatasourceSubscriptionOptions, + SubscriptionDataKey +} from '@core/api/datasource-subcription'; +import { deepClone } from '@core/utils'; export interface DatasourceListener { + subscriptionType: widgetType; + subscriptionTimewindow: SubscriptionTimewindow; + datasource: Datasource; entityType: EntityType; entityId: string; + datasourceIndex: number; + dataUpdated: (data: DataSetHolder, datasourceIndex: number, dataKeyIndex: number, detectChanges: boolean) => void; + updateRealtimeSubscription: () => SubscriptionTimewindow; + setRealtimeSubscription: (subscriptionTimewindow: SubscriptionTimewindow) => void; + datasourceSubscriptionKey?: number; } @Injectable({ @@ -29,14 +45,65 @@ export interface DatasourceListener { }) export class DatasourceService { + private subscriptions: {[datasourceSubscriptionKey: string]: DatasourceSubscription} = {}; + constructor(private telemetryService: TelemetryWebsocketService, private utils: UtilsService) {} public subscribeToDatasource(listener: DatasourceListener) { - // TODO: + const datasource = listener.datasource; + if (datasource.type === DatasourceType.entity && (!listener.entityId || !listener.entityType)) { + return; + } + const subscriptionDataKeys: Array = []; + datasource.dataKeys.forEach((dataKey) => { + const subscriptionDataKey: SubscriptionDataKey = { + name: dataKey.name, + type: dataKey.type, + funcBody: dataKey.funcBody, + postFuncBody: dataKey.postFuncBody + }; + subscriptionDataKeys.push(subscriptionDataKey); + }); + + const datasourceSubscriptionOptions: DatasourceSubscriptionOptions = { + datasourceType: datasource.type, + dataKeys: subscriptionDataKeys, + type: listener.subscriptionType + }; + + if (listener.subscriptionType === widgetType.timeseries) { + datasourceSubscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow); + } + if (datasourceSubscriptionOptions.datasourceType === DatasourceType.entity) { + datasourceSubscriptionOptions.entityType = listener.entityType; + datasourceSubscriptionOptions.entityId = listener.entityId; + } + listener.datasourceSubscriptionKey = this.utils.objectHashCode(datasourceSubscriptionOptions); + let subscription: DatasourceSubscription; + if (this.subscriptions[listener.datasourceSubscriptionKey]) { + subscription = this.subscriptions[listener.datasourceSubscriptionKey]; + subscription.syncListener(listener); + } else { + subscription = new DatasourceSubscription(datasourceSubscriptionOptions, + this.telemetryService, this.utils); + this.subscriptions[listener.datasourceSubscriptionKey] = subscription; + subscription.start(); + } + subscription.addListener(listener); } public unsubscribeFromDatasource(listener: DatasourceListener) { - // TODO: + if (listener.datasourceSubscriptionKey) { + const subscription = this.subscriptions[listener.datasourceSubscriptionKey]; + if (subscription) { + subscription.removeListener(listener); + if (!subscription.hasListeners()) { + subscription.unsubscribe(); + delete this.subscriptions[listener.datasourceSubscriptionKey]; + } + } + listener.datasourceSubscriptionKey = null; + } } } diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 2b52a73494..9afaa2053d 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -28,14 +28,15 @@ import { TimeService } from '../services/time.service'; import { DeviceService } from '../http/device.service'; import { AlarmService } from '../http/alarm.service'; import { UtilsService } from '@core/services/utils.service'; -import { Timewindow } from '@shared/models/time/time.models'; +import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; import { EntityType } from '@shared/models/entity-type.models'; import { AlarmSearchStatus } from '@shared/models/alarm.models'; import { HttpErrorResponse } from '@angular/common/http'; import { DatasourceService } from '@core/api/datasource.service'; +import { RafService } from '@core/services/raf.service'; export interface TimewindowFunctions { - onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval: number) => void; + onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; onResetTimewindow: () => void; } @@ -128,6 +129,7 @@ export interface WidgetSubscriptionContext { alarmService: AlarmService; datasourceService: DatasourceService; utils: UtilsService; + raf: RafService; widgetUtils: IWidgetUtils; dashboardTimewindowApi: TimewindowFunctions; getServerTimeDiff: () => Observable; @@ -137,10 +139,10 @@ export interface WidgetSubscriptionContext { } export interface WidgetSubscriptionCallbacks { - onDataUpdated?: (subscription: IWidgetSubscription) => void; + onDataUpdated?: (subscription: IWidgetSubscription, detectChanges: boolean) => void; onDataUpdateError?: (subscription: IWidgetSubscription, e: any) => void; dataLoading?: (subscription: IWidgetSubscription) => void; - legendDataUpdated?: (subscription: IWidgetSubscription) => void; + legendDataUpdated?: (subscription: IWidgetSubscription, detectChanges: boolean) => void; timeWindowUpdated?: (subscription: IWidgetSubscription, timeWindowConfig: Timewindow) => void; rpcStateChanged?: (subscription: IWidgetSubscription) => void; onRpcSuccess?: (subscription: IWidgetSubscription) => void; @@ -184,7 +186,8 @@ export interface IWidgetSubscription { datasources?: Array; data?: Array; hiddenData?: Array<{data: DataSet}>; - timeWindow?: Timewindow; + timeWindowConfig?: Timewindow; + timeWindow?: WidgetTimewindow; alarmSource?: Datasource; alarmSearchStatus?: AlarmSearchStatus; @@ -202,7 +205,11 @@ export interface IWidgetSubscription { onAliasesChanged(aliasIds: Array): boolean; - onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval: number): void; + onDashboardTimewindowChanged(dashboardTimewindow: Timewindow): void; + + updateDataVisibility(index: number): void; + + onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval?: number): void; onResetTimewindow(): void; updateTimewindowConfig(newTimewindow: Timewindow): void; diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index b4cd76d0c0..9d1ebd3b46 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -23,8 +23,10 @@ import { } from '@core/api/widget-api.models'; import { DataSet, + DataSetHolder, Datasource, DatasourceData, + DatasourceType, LegendConfig, LegendData, LegendKey, @@ -32,7 +34,13 @@ import { widgetType } from '@app/shared/models/widget.models'; import { HttpErrorResponse } from '@angular/common/http'; -import { Timewindow } from '@app/shared/models/time/time.models'; +import { + createSubscriptionTimewindow, + SubscriptionTimewindow, + Timewindow, + toHistoryTimewindow, + WidgetTimewindow +} from '@app/shared/models/time/time.models'; import { Observable, of, ReplaySubject, Subject } from 'rxjs'; import { CancelAnimationFrame } from '@core/services/raf.service'; import { EntityType } from '@shared/models/entity-type.models'; @@ -40,6 +48,7 @@ import { AlarmInfo, AlarmSearchStatus } from '@shared/models/alarm.models'; import { deepClone, isDefined } from '@core/utils'; import { AlarmSourceListener } from '@core/http/alarm.service'; import { DatasourceListener } from '@core/api/datasource.service'; +import * as deepEqual from 'deep-equal'; export class WidgetSubscription implements IWidgetSubscription { @@ -48,16 +57,16 @@ export class WidgetSubscription implements IWidgetSubscription { type: widgetType; callbacks: WidgetSubscriptionCallbacks; - timeWindow: Timewindow; + timeWindow: WidgetTimewindow; originalTimewindow: Timewindow; timeWindowConfig: Timewindow; - subscriptionTimewindow: Timewindow; + subscriptionTimewindow: SubscriptionTimewindow; useDashboardTimewindow: boolean; data: Array; datasources: Array; datasourceListeners: Array; - hiddenData: Array<{ data: DataSet }>; + hiddenData: Array; legendData: LegendData; legendConfig: LegendConfig; caulculateLegendData: boolean; @@ -323,17 +332,95 @@ export class WidgetSubscription implements IWidgetSubscription { } } + private resetData() { + for (let i = 0; i < this.data.length; i++) { + this.data[i].data = []; + this.hiddenData[i].data = []; + if (this.displayLegend) { + this.legendData.data[i].min = null; + this.legendData.data[i].max = null; + this.legendData.data[i].avg = null; + this.legendData.data[i].total = null; + this.legendData.data[i].hidden = false; + } + } + this.onDataUpdated(); + } + getFirstEntityInfo(): EntityInfo { return undefined; } + onAliasesChanged(aliasIds: Array): boolean { + return false; + } + + private onDataUpdated(detectChanges?: boolean) { + if (this.cafs.dataUpdated) { + this.cafs.dataUpdated(); + this.cafs.dataUpdated = null; + } + this.cafs.dataUpdated = this.ctx.raf.raf(() => { + try { + this.callbacks.onDataUpdated(this, detectChanges); + } catch (e) { + this.callbacks.onDataUpdateError(this, e); + } + }); + } + + onDashboardTimewindowChanged(newDashboardTimewindow: Timewindow): void { + if (this.type === widgetType.timeseries || this.type === widgetType.alarm) { + if (this.useDashboardTimewindow) { + if (!deepEqual(this.timeWindowConfig, newDashboardTimewindow) && newDashboardTimewindow) { + this.timeWindowConfig = deepClone(newDashboardTimewindow); + this.update(); + } + } + } + } + + updateDataVisibility(index: number): void { + if (this.displayLegend) { + const hidden = this.legendData.keys[index].dataKey.hidden; + if (hidden) { + this.hiddenData[index].data = this.data[index].data; + this.data[index].data = []; + } else { + this.data[index].data = this.hiddenData[index].data; + this.hiddenData[index].data = []; + } + this.onDataUpdated(); + } + } + updateTimewindowConfig(newTimewindow: Timewindow): void { } onResetTimewindow(): void { + if (this.useDashboardTimewindow) { + this.ctx.dashboardTimewindowApi.onResetTimewindow(); + } else { + if (this.originalTimewindow) { + this.timeWindowConfig = deepClone(this.originalTimewindow); + this.originalTimewindow = null; + this.callbacks.timeWindowUpdated(this, this.timeWindowConfig); + this.update(); + } + } } - onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval: number): void { + onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval?: number): void { + if (this.useDashboardTimewindow) { + this.ctx.dashboardTimewindowApi.onUpdateTimewindow(startTimeMs, endTimeMs); + } else { + if (!this.originalTimewindow) { + this.originalTimewindow = deepClone(this.timeWindowConfig); + } + this.timeWindowConfig = toHistoryTimewindow(this.timeWindowConfig, startTimeMs, endTimeMs, interval, this.ctx.timeService); + this.callbacks.timeWindowUpdated(this, this.timeWindowConfig); + this.update(); + } } sendOneWayCommand(method: string, params?: any, timeout?: number): Observable { @@ -347,12 +434,109 @@ export class WidgetSubscription implements IWidgetSubscription { clearRpcError(): void { } + update() { + this.unsubscribe(); + this.subscribe(); + } + subscribe(): void { + if (this.cafs.subscribe) { + this.cafs.subscribe(); + this.cafs.subscribe = null; + } + this.cafs.subscribe = this.ctx.raf.raf(() => { + this.doSubscribe(); + }); + } + + private doSubscribe() { + if (this.type === widgetType.rpc) { + return; + } + if (this.type === widgetType.alarm) { + this.alarmsSubscribe(); + } else { + this.notifyDataLoading(); + if (this.type === widgetType.timeseries && this.timeWindowConfig) { + this.updateRealtimeSubscription(); + if (this.subscriptionTimewindow.fixedWindow) { + this.onDataUpdated(); + } + } + let index = 0; + this.datasources.forEach((datasource) => { + const listener: DatasourceListener = { + subscriptionType: this.type, + subscriptionTimewindow: this.subscriptionTimewindow, + datasource, + entityType: datasource.entityType, + entityId: datasource.entityId, + dataUpdated: this.dataUpdated.bind(this), + updateRealtimeSubscription: () => { + this.subscriptionTimewindow = this.updateRealtimeSubscription(); + return this.subscriptionTimewindow; + }, + setRealtimeSubscription: (subscriptionTimewindow) => { + this.updateRealtimeSubscription(deepClone(subscriptionTimewindow)); + }, + datasourceIndex: index + }; + for (let a = 0; a < datasource.dataKeys.length; a++) { + this.data[index + a].data = []; + } + index += datasource.dataKeys.length; + this.datasourceListeners.push(listener); + + if (datasource.dataKeys.length) { + this.ctx.datasourceService.subscribeToDatasource(listener); + } + let forceUpdate = false; + if (datasource.unresolvedStateEntity || + !datasource.dataKeys.length || + (datasource.type === DatasourceType.entity && !datasource.entityId) + ) { + forceUpdate = true; + } + if (forceUpdate) { + this.notifyDataLoaded(); + this.onDataUpdated(); + } + }); + } + } + + private alarmsSubscribe() { + // TODO: + } + + + unsubscribe() { + if (this.type !== widgetType.rpc) { + if (this.type === widgetType.alarm) { + this.alarmsUnsubscribe(); + } else { + this.datasourceListeners.forEach((listener) => { + this.ctx.datasourceService.unsubscribeFromDatasource(listener); + }); + this.datasourceListeners.length = 0; + this.resetData(); + } + } + } + + private alarmsUnsubscribe() { // TODO: - this.notifyDataLoaded(); } destroy(): void { + this.unsubscribe(); + for (const cafId of Object.keys(this.cafs)) { + if (this.cafs[cafId]) { + this.cafs[cafId](); + this.cafs[cafId] = null; + } + } + // TODO: } private notifyDataLoading() { @@ -365,10 +549,89 @@ export class WidgetSubscription implements IWidgetSubscription { this.callbacks.dataLoading(this); } - onAliasesChanged(aliasIds: Array): boolean { - return false; + private updateTimewindow() { + this.timeWindow.interval = this.subscriptionTimewindow.aggregation.interval || 1000; + if (this.subscriptionTimewindow.realtimeWindowMs) { + this.timeWindow.maxTime = Date.now() + this.timeWindow.stDiff; + this.timeWindow.minTime = this.timeWindow.maxTime - this.subscriptionTimewindow.realtimeWindowMs; + } else if (this.subscriptionTimewindow.fixedWindow) { + this.timeWindow.maxTime = this.subscriptionTimewindow.fixedWindow.endTimeMs; + this.timeWindow.minTime = this.subscriptionTimewindow.fixedWindow.startTimeMs; + } + } + + private updateRealtimeSubscription(subscriptionTimewindow?: SubscriptionTimewindow) { + if (subscriptionTimewindow) { + this.subscriptionTimewindow = subscriptionTimewindow; + } else { + this.subscriptionTimewindow = + createSubscriptionTimewindow(this.timeWindowConfig, this.timeWindow.stDiff, + this.stateData, this.ctx.timeService); + } + this.updateTimewindow(); + return this.subscriptionTimewindow; + } + + private dataUpdated(sourceData: DataSetHolder, datasourceIndex: number, dataKeyIndex: number, detectChanges: boolean) { + for (let x = 0; x < this.datasourceListeners.length; x++) { + this.datasources[x].dataReceived = this.datasources[x].dataReceived === true; + if (this.datasourceListeners[x].datasourceIndex === datasourceIndex && sourceData.data.length > 0) { + this.datasources[x].dataReceived = true; + } + } + this.notifyDataLoaded(); + let update = true; + let currentData: DataSetHolder; + if (this.displayLegend && this.legendData.keys[datasourceIndex + dataKeyIndex].dataKey.hidden) { + currentData = this.hiddenData[datasourceIndex + dataKeyIndex]; + } else { + currentData = this.data[datasourceIndex + dataKeyIndex]; + } + if (this.type === widgetType.latest) { + const prevData = currentData.data; + if (!sourceData.data.length) { + update = false; + } else if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.data.length > 0) { + const prevTs = prevData[0][0]; + const prevValue = prevData[0][1]; + if (prevTs === sourceData.data[0][0] && prevValue === sourceData.data[0][1]) { + update = false; + } + } + } + if (update) { + if (this.subscriptionTimewindow && this.subscriptionTimewindow.realtimeWindowMs) { + this.updateTimewindow(); + } + currentData.data = sourceData.data; + if (this.caulculateLegendData) { + this.updateLegend(datasourceIndex + dataKeyIndex, sourceData.data, detectChanges); + } + this.onDataUpdated(detectChanges); + } + } + + private updateLegend(dataIndex: number, data: DataSet, detectChanges: boolean) { + const dataKey = this.legendData.keys[dataIndex].dataKey; + const decimals = isDefined(dataKey.decimals) ? dataKey.decimals : this.decimals; + const units = dataKey.units && dataKey.units.length ? dataKey.units : this.units; + const legendKeyData = this.legendData.data[dataIndex]; + if (this.legendConfig.showMin) { + legendKeyData.min = this.ctx.widgetUtils.formatValue(calculateMin(data), decimals, units); + } + if (this.legendConfig.showMax) { + legendKeyData.max = this.ctx.widgetUtils.formatValue(calculateMax(data), decimals, units); + } + if (this.legendConfig.showAvg) { + legendKeyData.avg = this.ctx.widgetUtils.formatValue(calculateAvg(data), decimals, units); + } + if (this.legendConfig.showTotal) { + legendKeyData.total = this.ctx.widgetUtils.formatValue(calculateTotal(data), decimals, units); + } + this.callbacks.legendDataUpdated(this, detectChanges !== false); } + private loadStDiff(): Observable { const loadSubject = new ReplaySubject(1); if (this.ctx.getServerTimeDiff && this.timeWindow) { @@ -394,3 +657,47 @@ export class WidgetSubscription implements IWidgetSubscription { return loadSubject.asObservable(); } } + +function calculateMin(data: DataSet): number { + if (data.length > 0) { + let result = Number(data[0][1]); + for (let i = 1; i < data.length; i++) { + result = Math.min(result, Number(data[i][1])); + } + return result; + } else { + return null; + } +} + +function calculateMax(data: DataSet): number { + if (data.length > 0) { + let result = Number(data[0][1]); + for (let i = 1; i < data.length; i++) { + result = Math.max(result, Number(data[i][1])); + } + return result; + } else { + return null; + } +} + +function calculateAvg(data: DataSet): number { + if (data.length > 0) { + return calculateTotal(data) / data.length; + } else { + return null; + } +} + +function calculateTotal(data: DataSet): number { + if (data.length > 0) { + let result = 0; + data.forEach((dataRow) => { + result += Number(dataRow[1]); + }); + return result; + } else { + return null; + } +} diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 272b5ce3a7..6622051e8d 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -54,6 +54,32 @@ export class UtilsService { } } + public hashCode(str: string): number { + let hash = 0; + let i: number; + let char: number; + if (str.length === 0) { + return hash; + } + for (i = 0; i < str.length; i++) { + char = str.charCodeAt(i); + // tslint:disable-next-line:no-bitwise + hash = ((hash << 5) - hash) + char; + // tslint:disable-next-line:no-bitwise + hash = hash & hash; // Convert to 32bit integer + } + return hash; + } + + public objectHashCode(obj: any): number { + let hash = 0; + if (obj) { + const str = JSON.stringify(obj); + hash = this.hashCode(str); + } + return hash; + } + public processWidgetException(exception: any): ExceptionData { const data = this.parseException(exception, -5); if (this.widgetEditMode) { @@ -203,4 +229,9 @@ export class UtilsService { }); } + public currentPerfTime(): number { + return this.window.performance && this.window.performance.now ? + this.window.performance.now() : Date.now(); + } + } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index b5022eb7cb..8cf2cdc59b 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -79,10 +79,22 @@ export function isDefined(value: any): boolean { return typeof value !== 'undefined'; } +export function isDefinedAndNotNull(value: any): boolean { + return typeof value !== 'undefined' && value !== null; +} + export function isFunction(value: any): boolean { return typeof value === 'function'; } +export function isObject(value: any): boolean { + return value !== null && typeof value === 'object'; +} + +export function isNumber(value: any): boolean { + return typeof value === 'number'; +} + export function objToBase64(obj: any): string { const json = JSON.stringify(obj); const encoded = utf8Encode(json); @@ -295,10 +307,24 @@ function utf8ToBytes(input: string, units?: number): number[] { return bytes; } -export function deepClone(obj: T): T { - if (obj) { - return JSON.parse(JSON.stringify(obj)); - } else { - return obj; +export function deepClone(target: T): T { + if (target === null) { + return target; + } + if (target instanceof Date) { + return new Date(target.getTime()) as any; + } + if (target instanceof Array) { + const cp = [] as any[]; + (target as any[]).forEach((v) => { cp.push(v); }); + return cp.map((n: any) => deepClone(n)) as any; + } + if (typeof target === 'object' && target !== {}) { + const cp = { ...(target as { [key: string]: any }) } as { [key: string]: any }; + Object.keys(cp).forEach(k => { + cp[k] = deepClone(cp[k]); + }); + return cp as T; } + return target; } diff --git a/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts b/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts index 3eee1d4f02..89b50cf0a0 100644 --- a/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts +++ b/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, NgZone } from '@angular/core'; import { AttributesSubscriptionCmd, GetHistoryCmd, @@ -66,6 +66,7 @@ export class TelemetryWebsocketService implements TelemetryService { constructor(private store: Store, private authService: AuthService, + private ngZone: NgZone, @Inject(WINDOW) private window: Window) { this.store.pipe(select(selectIsAuthenticated)).subscribe( (authenticated: boolean) => { @@ -222,7 +223,9 @@ export class TelemetryWebsocketService implements TelemetryService { ); this.dataStream.subscribe((message) => { - this.onMessage(message as SubscriptionUpdateMsg); + this.ngZone.runOutsideAngular(() => { + this.onMessage(message as SubscriptionUpdateMsg); + }); }, (error) => { this.onError(error); diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index f20bf2beba..bf11051bec 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -39,7 +39,7 @@ import { IDashboardComponent, WidgetsData } from '../../models/dashboard-component.models'; -import { merge, Observable } from 'rxjs'; +import { merge, Observable, ReplaySubject, Subject } from 'rxjs'; import { map, share, tap } from 'rxjs/operators'; import { WidgetLayout } from '@shared/models/dashboard.models'; import { DialogService } from '@core/services/dialog.service'; @@ -117,6 +117,10 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo @Input() dashboardTimewindow: Timewindow; + dashboardTimewindowChangedSubject: Subject = new ReplaySubject(); + + dashboardTimewindowChanged = this.dashboardTimewindowChangedSubject.asObservable(); + originalDashboardTimewindow: Timewindow; gridsterOpts: GridsterConfig; @@ -262,18 +266,20 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo ngAfterViewInit(): void { } - onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval: number): void { + onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval?: number): void { if (!this.originalDashboardTimewindow) { this.originalDashboardTimewindow = deepClone(this.dashboardTimewindow); } this.dashboardTimewindow = toHistoryTimewindow(this.dashboardTimewindow, startTimeMs, endTimeMs, interval, this.timeService); + this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); } onResetTimewindow(): void { if (this.originalDashboardTimewindow) { this.dashboardTimewindow = deepClone(this.originalDashboardTimewindow); this.originalDashboardTimewindow = null; + this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); } } diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index 42f17f6c41..d3eb695f44 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -158,7 +158,7 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn if (this.entitiesTableConfig.useTimePageLink) { this.timewindow = historyInterval(24 * 60 * 60 * 1000); - const currentTime = new Date().getTime(); + const currentTime = Date.now(); this.pageLink = new TimePageLink(10, 0, null, sortOrder, currentTime - this.timewindow.history.timewindowMs, currentTime); } else { @@ -216,7 +216,7 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn if (this.entitiesTableConfig.useTimePageLink) { const timePageLink = this.pageLink as TimePageLink; if (this.timewindow.history.timewindowMs) { - const currentTime = new Date().getTime(); + const currentTime = Date.now(); timePageLink.startTime = currentTime - this.timewindow.history.timewindowMs; timePageLink.endTime = currentTime; } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts index 47ddfc3912..48ece2ac81 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts @@ -21,6 +21,7 @@ import { AppState } from '@core/core.state'; import { WidgetContext, IDynamicWidgetComponent } from '@home/models/widget-component.models'; import { ExceptionData } from '@shared/models/error.models'; import { HttpErrorResponse } from '@angular/common/http'; +import { RafService } from '@core/services/raf.service'; export class DynamicWidgetComponent extends PageComponent implements IDynamicWidgetComponent, OnInit, OnDestroy { @@ -37,7 +38,8 @@ export class DynamicWidgetComponent extends PageComponent implements IDynamicWid [key: string]: any; - constructor(protected store: Store) { + constructor(public raf: RafService, + protected store: Store) { super(store); } diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.scss b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss index 8df6fc5077..a30061421b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/legend.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss @@ -29,10 +29,14 @@ padding: 0 10px 1px 0; color: rgb(255, 110, 64); white-space: nowrap; + font-size: 12px; } } .tb-legend-keys { + td { + font-size: 12px; + } td.tb-legend-label, td.tb-legend-value { padding: 2px 10px; diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.ts b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts index aab873e419..455fe58d73 100644 --- a/ui-ngx/src/app/modules/home/components/widget/legend.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { LegendConfig, LegendData, LegendDirection, LegendPosition } from '@shared/models/widget.models'; @Component({ @@ -30,6 +30,9 @@ export class LegendComponent implements OnInit { @Input() legendData: LegendData; + @Output() + legendKeyHiddenChange = new EventEmitter(); + displayHeader: boolean; isHorizontal: boolean; @@ -50,6 +53,7 @@ export class LegendComponent implements OnInit { toggleHideData(index: number) { this.legendData.keys[index].dataKey.hidden = !this.legendData.keys[index].dataKey.hidden; + this.legendKeyHiddenChange.emit(index); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts new file mode 100644 index 0000000000..767b7109b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts @@ -0,0 +1,592 @@ +/// +/// Copyright © 2016-2019 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 { JsonSettingsSchema, DataKey, DatasourceData } from '@shared/models/widget.models'; + +export declare type ChartType = 'line' | 'pie' | 'bar' | 'state' | 'graph'; + +export declare type TbFlotSettings = TbFlotBaseSettings & TbFlotGraphSettings & TbFlotBarSettings & TbFlotPieSettings; + +export declare type TooltipValueFormatFunction = (value: any) => string; + +export declare type TbFlotTicksFormatterFunction = (t: number, a?: TbFlotPlotAxis) => string; + +export interface TbFlotSeries extends DatasourceData, JQueryPlotSeriesOptions { + dataKey: TbFlotDataKey; + yaxisIndex?: number; + yaxis?: number; +} + +export interface TbFlotDataKey extends DataKey { + settings?: TbFlotKeySettings; + tooltipValueFormatFunction?: TooltipValueFormatFunction; +} + +export interface TbFlotPlotAxis extends JQueryPlotAxis, TbFlotAxisOptions { + options: TbFlotAxisOptions; +} + +export interface TbFlotAxisOptions extends JQueryPlotAxisOptions { + tickUnits?: string; + hidden?: boolean; + keysInfo?: Array<{hidden: boolean}>; + ticksFormatterFunction?: TbFlotTicksFormatterFunction; +} + +export interface TbFlotPlotDataSeries extends JQueryPlotDataSeries { + dataKey?: TbFlotDataKey; + percent?: number; +} + +export interface TbFlotPlotItem extends jquery.flot.item { + series: TbFlotPlotDataSeries; +} + +export interface TbFlotHoverInfo { + seriesHover: Array; + time?: any; +} + +export interface TbFlotSeriesHoverInfo { + hoverIndex: number; + units: string; + decimals: number; + label: string; + color: string; + index: number; + tooltipValueFormatFunction: TooltipValueFormatFunction; + value: any; + time: any; + distance: number; +} + +export interface TbFlotGridSettings { + color: string; + backgroundColor: string; + tickColor: string; + outlineWidth: number; + verticalLines: boolean; + horizontalLines: boolean; + minBorderMargin: number; + margin: number; +} + +export interface TbFlotXAxisSettings { + showLabels: boolean; + title: string; + color: boolean; +} + +export interface TbFlotYAxisSettings { + min: number; + max: number; + showLabels: boolean; + title: string; + color: string; + ticksFormatter: string; + tickDecimals: number; + tickSize: number; +} + +export interface TbFlotBaseSettings { + stack: boolean; + shadowSize: number; + fontColor: string; + fontSize: number; + tooltipIndividual: boolean; + tooltipCumulative: boolean; + tooltipValueFormatter: string; + grid: TbFlotGridSettings; + xaxis: TbFlotXAxisSettings; + yaxis: TbFlotYAxisSettings; +} + +export interface TbFlotGraphSettings extends TbFlotBaseSettings { + smoothLines: boolean; +} + +export interface TbFlotBarSettings extends TbFlotBaseSettings { + defaultBarWidth: number; +} + +export interface TbFlotPieSettings { + radius: number; + innerRadius: number; + tilt: number; + animatedPie: boolean; + stroke: { + color: string; + width: number; + }; + showLabels: boolean; + fontColor: string; + fontSize: number; +} + +export declare type TbFlotYAxisPosition = 'left' | 'right'; + +export interface TbFlotKeySettings { + showLines: boolean; + fillLines: boolean; + showPoints: boolean; + lineWidth: number; + tooltipValueFormatter: string; + showSeparateAxis: boolean; + axisMin: number; + axisMax: number; + axisTitle: string; + axisTickDecimals: number; + axisTickSize: number; + axisPosition: TbFlotYAxisPosition; + axisTicksFormatter: string; +} + +export function flotSettingsSchema(chartType: ChartType): JsonSettingsSchema { + + const schema: JsonSettingsSchema = { + schema: { + type: 'object', + title: 'Settings', + properties: { + } + } + }; + + const properties: any = schema.schema.properties; + properties.stack = { + title: 'Stacking', + type: 'boolean', + default: false + }; + if (chartType === 'graph') { + properties.smoothLines = { + title: 'Display smooth (curved) lines', + type: 'boolean', + default: false + }; + } + if (chartType === 'bar') { + properties.defaultBarWidth = { + title: 'Default bar width for non-aggregated data (milliseconds)', + type: 'number', + default: 600 + }; + } + properties.shadowSize = { + title: 'Shadow size', + type: 'number', + default: 4 + }; + properties.fontColor = { + title: 'Font color', + type: 'string', + default: '#545454' + }; + properties.fontSize = { + title: 'Font size', + type: 'number', + default: 10 + }; + properties.tooltipIndividual = { + title: 'Hover individual points', + type: 'boolean', + default: false + }; + properties.tooltipCumulative = { + title: 'Show cumulative values in stacking mode', + type: 'boolean', + default: false + }; + properties.tooltipValueFormatter = { + title: 'Tooltip value format function, f(value)', + type: 'string', + default: '' + }; + + properties.grid = { + title: 'Grid settings', + type: 'object', + properties: { + color: { + title: 'Primary color', + type: 'string', + default: '#545454' + }, + backgroundColor: { + title: 'Background color', + type: 'string', + default: null + }, + tickColor: { + title: 'Ticks color', + type: 'string', + default: '#DDDDDD' + }, + outlineWidth: { + title: 'Grid outline/border width (px)', + type: 'number', + default: 1 + }, + verticalLines: { + title: 'Show vertical lines', + type: 'boolean', + default: true + }, + horizontalLines: { + title: 'Show horizontal lines', + type: 'boolean', + default: true + } + } + }; + + properties.xaxis = { + title: 'X axis settings', + type: 'object', + properties: { + showLabels: { + title: 'Show labels', + type: 'boolean', + default: true + }, + title: { + title: 'Axis title', + type: 'string', + default: null + }, + color: { + title: 'Ticks color', + type: 'string', + default: null + } + } + }; + + properties.yaxis = { + title: 'Y axis settings', + type: 'object', + properties: { + min: { + title: 'Minimum value on the scale', + type: 'number', + default: null + }, + max: { + title: 'Maximum value on the scale', + type: 'number', + default: null + }, + showLabels: { + title: 'Show labels', + type: 'boolean', + default: true + }, + title: { + title: 'Axis title', + type: 'string', + default: null + }, + color: { + title: 'Ticks color', + type: 'string', + default: null + }, + ticksFormatter: { + title: 'Ticks formatter function, f(value)', + type: 'string', + default: '' + }, + tickDecimals: { + title: 'The number of decimals to display', + type: 'number', + default: 0 + }, + tickSize: { + title: 'Step size between ticks', + type: 'number', + default: null + } + } + }; + + schema.schema.required = []; + schema.form = ['stack']; + if (chartType === 'graph') { + schema.form.push('smoothLines'); + } + if (chartType === 'bar') { + schema.form.push('defaultBarWidth'); + } + schema.form.push('shadowSize'); + schema.form.push({ + key: 'fontColor', + type: 'color' + }); + schema.form.push('fontSize'); + schema.form.push('tooltipIndividual'); + schema.form.push('tooltipCumulative'); + schema.form.push({ + key: 'tooltipValueFormatter', + type: 'javascript' + }); + schema.form.push({ + key: 'grid', + items: [ + { + key: 'grid.color', + type: 'color' + }, + { + key: 'grid.backgroundColor', + type: 'color' + }, + { + key: 'grid.tickColor', + type: 'color' + }, + 'grid.outlineWidth', + 'grid.verticalLines', + 'grid.horizontalLines' + ] + }); + schema.form.push({ + key: 'xaxis', + items: [ + 'xaxis.showLabels', + 'xaxis.title', + { + key: 'xaxis.color', + type: 'color' + } + ] + }); + schema.form.push({ + key: 'yaxis', + items: [ + 'yaxis.min', + 'yaxis.max', + 'yaxis.tickDecimals', + 'yaxis.tickSize', + 'yaxis.showLabels', + 'yaxis.title', + { + key: 'yaxis.color', + type: 'color' + }, + { + key: 'yaxis.ticksFormatter', + type: 'javascript' + } + ] + }); + return schema; +} + +export function flotPieSettingsSchema(): JsonSettingsSchema { + return { + schema: { + type: 'object', + title: 'Settings', + properties: { + radius: { + title: 'Radius', + type: 'number', + default: 1 + }, + innerRadius: { + title: 'Inner radius', + type: 'number', + default: 0 + }, + tilt: { + title: 'Tilt', + type: 'number', + default: 1 + }, + animatedPie: { + title: 'Enable pie animation (experimental)', + type: 'boolean', + default: false + }, + stroke: { + title: 'Stroke', + type: 'object', + properties: { + color: { + title: 'Color', + type: 'string', + default: '' + }, + width: { + title: 'Width (pixels)', + type: 'number', + default: 0 + } + } + }, + showLabels: { + title: 'Show labels', + type: 'boolean', + default: false + }, + fontColor: { + title: 'Font color', + type: 'string', + default: '#545454' + }, + fontSize: { + title: 'Font size', + type: 'number', + default: 10 + } + }, + required: [] + }, + form: [ + 'radius', + 'innerRadius', + 'animatedPie', + 'tilt', + { + key: 'stroke', + items: [ + { + key: 'stroke.color', + type: 'color' + }, + 'stroke.width' + ] + }, + 'showLabels', + { + key: 'fontColor', + type: 'color' + }, + 'fontSize' + ] + }; +} + +export function flotDatakeySettingsSchema(defaultShowLines: boolean): JsonSettingsSchema { + return { + schema: { + type: 'object', + title: 'DataKeySettings', + properties: { + showLines: { + title: 'Show lines', + type: 'boolean', + default: defaultShowLines + }, + fillLines: { + title: 'Fill lines', + type: 'boolean', + default: false + }, + showPoints: { + title: 'Show points', + type: 'boolean', + default: false + }, + lineWidth: { + title: 'Line width', + type: 'number', + default: null + }, + tooltipValueFormatter: { + title: 'Tooltip value format function, f(value)', + type: 'string', + default: '' + }, + showSeparateAxis: { + title: 'Show separate axis', + type: 'boolean', + default: false + }, + axisMin: { + title: 'Minimum value on the axis scale', + type: 'number', + default: null + }, + axisMax: { + title: 'Maximum value on the axis scale', + type: 'number', + default: null + }, + axisTitle: { + title: 'Axis title', + type: 'string', + default: '' + }, + axisTickDecimals: { + title: 'Axis tick number of digits after floating point', + type: 'number', + default: null + }, + axisTickSize: { + title: 'Axis step size between ticks', + type: 'number', + default: null + }, + axisPosition: { + title: 'Axis position', + type: 'string', + default: 'left' + }, + axisTicksFormatter: { + title: 'Ticks formatter function, f(value)', + type: 'string', + default: '' + } + }, + required: ['showLines', 'fillLines', 'showPoints'] + }, + form: [ + 'showLines', + 'fillLines', + 'showPoints', + { + key: 'tooltipValueFormatter', + type: 'javascript' + }, + 'showSeparateAxis', + 'axisMin', + 'axisMax', + 'axisTitle', + 'axisTickDecimals', + 'axisTickSize', + { + key: 'axisPosition', + type: 'rc-select', + multiple: false, + items: [ + { + value: 'left', + label: 'Left' + }, + { + value: 'right', + label: 'Right' + } + ] + }, + { + key: 'axisTicksFormatter', + type: 'javascript' + } + ] + }; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts new file mode 100644 index 0000000000..8f8096f04e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts @@ -0,0 +1,1074 @@ +/// +/// Copyright © 2016-2019 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 { WidgetContext } from '@home/models/widget-component.models'; +import { deepClone, isDefined, isNumber, isUndefined } from '@app/core/utils'; +import { IWidgetSubscription } from '@core/api/widget-api.models'; +import { DatasourceData, JsonSettingsSchema } from '@app/shared/models/widget.models'; +import { + ChartType, + flotDatakeySettingsSchema, + flotPieSettingsSchema, + flotSettingsSchema, + TbFlotAxisOptions, + TbFlotHoverInfo, + TbFlotKeySettings, + TbFlotPlotAxis, + TbFlotPlotDataSeries, + TbFlotPlotItem, + TbFlotSeries, + TbFlotSeriesHoverInfo, + TbFlotSettings, + TbFlotTicksFormatterFunction, + TooltipValueFormatFunction +} from './flot-widget.models'; +import * as moment from 'moment'; +import * as tinycolor from 'tinycolor2'; +import { AggregationType } from '@shared/models/time/time.models'; +import { CancelAnimationFrame } from '@core/services/raf.service'; +import Timeout = NodeJS.Timeout; + +export class TbFlot { + + private settings: TbFlotSettings; + + private tooltip: JQuery; + + private yAxisTickFormatter: TbFlotTicksFormatterFunction; + private ticksFormatterFunction: TbFlotTicksFormatterFunction; + private yaxis: TbFlotAxisOptions; + private yaxes: Array; + + private options: JQueryPlotOptions; + private subscription: IWidgetSubscription; + private $element: JQuery; + + private trackUnits: string; + private trackDecimals: number; + private tooltipIndividual: boolean; + private tooltipCumulative: boolean; + + private defaultBarWidth: number; + + private plotInited = false; + private plot: JQueryPlot; + + private createPlotTimeoutHandle: Timeout; + private updateTimeoutHandle: Timeout; + private resizeTimeoutHandle: Timeout; + + private mouseEventsEnabled: boolean; + private isMouseInteraction = false; + private flotHoverHandler = this.onFlotHover.bind(this); + private flotSelectHandler = this.onFlotSelect.bind(this); + private dblclickHandler = this.onFlotDblClick.bind(this); + private mousedownHandler = this.onFlotMouseDown.bind(this); + private mouseupHandler = this.onFlotMouseUp.bind(this); + private mouseleaveHandler = this.onFlotMouseLeave.bind(this); + + private animatedPie: boolean; + private pieDataAnimationDuration: number; + private pieData: DatasourceData[]; + private pieRenderedData: any[]; + private pieTargetData: any[]; + private pieAnimationStartTime: number; + private pieAnimationLastTime: number; + private pieAnimationCaf: CancelAnimationFrame; + + static get pieSettingsSchema(): JsonSettingsSchema { + return flotPieSettingsSchema(); + } + + static get pieDatakeySettingsSchema(): JsonSettingsSchema { + return {}; + } + + static settingsSchema(chartType: ChartType): JsonSettingsSchema { + return flotSettingsSchema(chartType); + } + + static datakeySettingsSchema(defaultShowLines: boolean): JsonSettingsSchema { + return flotDatakeySettingsSchema(defaultShowLines); + } + + constructor(private ctx: WidgetContext, private chartType: ChartType) { + this.chartType = this.chartType || 'line'; + this.settings = ctx.settings as TbFlotSettings; + this.tooltip = $('#flot-series-tooltip'); + if (this.tooltip.length === 0) { + this.tooltip = this.createTooltipElement(); + } + + this.trackDecimals = ctx.decimals; + this.trackUnits = ctx.units; + this.tooltipIndividual = this.chartType === 'pie' || (isDefined(this.settings.tooltipIndividual) + ? this.settings.tooltipIndividual : false); + this.tooltipCumulative = isDefined(this.settings.tooltipCumulative) ? this.settings.tooltipCumulative : false; + + const font = { + color: this.settings.fontColor || '#545454', + size: this.settings.fontSize || 10, + family: 'Roboto' + }; + + this.options = { + title: null, + subtitile: null, + shadowSize: isDefined(this.settings.shadowSize) ? this.settings.shadowSize : 4, + HtmlText: false, + grid: { + hoverable: true, + mouseActiveRadius: 10, + autoHighlight: this.tooltipIndividual === true + }, + selection : { mode : ctx.isMobile ? null : 'x' }, + legend : { + show: false + } + }; + + if (this.chartType === 'line' || this.chartType === 'bar' || this.chartType === 'state') { + this.options.xaxis = { + mode: 'time', + timezone: 'browser', + font: deepClone(font), + labelFont: deepClone(font) + }; + this.yaxis = { + font: deepClone(font), + labelFont: deepClone(font) + }; + if (this.settings.xaxis) { + if (this.settings.xaxis.showLabels === false) { + this.options.xaxis.tickFormatter = () => { + return ''; + }; + } + this.options.xaxis.font.color = this.settings.xaxis.color || this.options.xaxis.font.color; + this.options.xaxis.label = this.settings.xaxis.title || null; + this.options.xaxis.labelFont.color = this.options.xaxis.font.color; + this.options.xaxis.labelFont.size = this.options.xaxis.font.size + 2; + this.options.xaxis.labelFont.weight = 'bold'; + } + + this.yAxisTickFormatter = this.formatYAxisTicks.bind(this); + + this.yaxis.tickFormatter = this.yAxisTickFormatter; + + if (this.settings.yaxis) { + this.yaxis.font.color = this.settings.yaxis.color || this.yaxis.font.color; + this.yaxis.min = isDefined(this.settings.yaxis.min) ? this.settings.yaxis.min : null; + this.yaxis.max = isDefined(this.settings.yaxis.max) ? this.settings.yaxis.max : null; + this.yaxis.label = this.settings.yaxis.title || null; + this.yaxis.labelFont.color = this.yaxis.font.color; + this.yaxis.labelFont.size = this.yaxis.font.size + 2; + this.yaxis.labelFont.weight = 'bold'; + if (isNumber(this.settings.yaxis.tickSize)) { + this.yaxis.tickSize = this.settings.yaxis.tickSize; + } else { + this.yaxis.tickSize = null; + } + if (isNumber(this.settings.yaxis.tickDecimals)) { + this.yaxis.tickDecimals = this.settings.yaxis.tickDecimals; + } else { + this.yaxis.tickDecimals = null; + } + if (this.settings.yaxis.ticksFormatter && this.settings.yaxis.ticksFormatter.length) { + try { + this.yaxis.ticksFormatterFunction = new Function('value', + this.settings.yaxis.ticksFormatter) as TbFlotTicksFormatterFunction; + } catch (e) { + this.yaxis.ticksFormatterFunction = null; + } + } + } + + this.options.grid.borderWidth = 1; + this.options.grid.color = this.settings.fontColor || '#545454'; + + if (this.settings.grid) { + this.options.grid.color = this.settings.grid.color || '#545454'; + this.options.grid.backgroundColor = this.settings.grid.backgroundColor || null; + this.options.grid.tickColor = this.settings.grid.tickColor || '#DDDDDD'; + this. options.grid.borderWidth = isDefined(this.settings.grid.outlineWidth) ? + this.settings.grid.outlineWidth : 1; + if (this.settings.grid.verticalLines === false) { + this.options.xaxis.tickLength = 0; + } + if (this.settings.grid.horizontalLines === false) { + this.yaxis.tickLength = 0; + } + if (isDefined(this.settings.grid.margin)) { + this.options.grid.margin = this.settings.grid.margin; + } + if (isDefined(this.settings.grid.minBorderMargin)) { + this.options.grid.minBorderMargin = this.settings.grid.minBorderMargin; + } + } + + this.options.crosshair = { + mode: 'x' + }; + + this.options.series = { + stack: this.settings.stack === true + }; + + if (this.chartType === 'line' && this.settings.smoothLines) { + this.options.series.curvedLines = { + active: true, + monotonicFit: true + }; + } + + if (this.chartType === 'bar') { + this.options.series.lines = { + show: false, + fill: false, + steps: false + }; + this.options.series.bars = { + show: true, + lineWidth: 0, + fill: 0.9 + }; + this.defaultBarWidth = this.settings.defaultBarWidth || 600; + } + + if (this.chartType === 'state') { + this.options.series.lines = { + steps: true, + show: true + }; + } + } else if (this.chartType === 'pie') { + this.options.series = { + pie: { + show: true, + label: { + show: this.settings.showLabels === true + }, + radius: this.settings.radius || 1, + innerRadius: this.settings.innerRadius || 0, + stroke: { + color: '#fff', + width: 0 + }, + tilt: this.settings.tilt || 1, + shadow: { + left: 5, + top: 15, + alpha: 0.02 + } + } + }; + if (this.settings.stroke) { + this.options.series.pie.stroke.color = this.settings.stroke.color || '#fff'; + this.options.series.pie.stroke.width = this.settings.stroke.width || 0; + } + + if (this.options.series.pie.label.show) { + this.options.series.pie.label.formatter = (label, series) => { + return `
${series.dataKey.label}
${Math.round(series.percent)}%
`; + }; + this.options.series.pie.label.radius = 3 / 4; + this.options.series.pie.label.background = { + opacity: 0.8 + }; + } + + // Experimental + this.animatedPie = this.settings.animatedPie === true; + + } + + if (this.ctx.defaultSubscription) { + this.init(this.ctx.$container, this.ctx.defaultSubscription); + } + } + + + private init($element: JQuery, subscription: IWidgetSubscription) { + this.subscription = subscription; + this.$element = $element; + const colors: string[] = []; + this.yaxes = []; + const yaxesMap: {[units: string]: TbFlotAxisOptions} = {}; + + let tooltipValueFormatFunction: TooltipValueFormatFunction = null; + if (this.settings.tooltipValueFormatter && this.settings.tooltipValueFormatter.length) { + try { + tooltipValueFormatFunction = new Function('value', this.settings.tooltipValueFormatter) as TooltipValueFormatFunction; + } catch (e) { + tooltipValueFormatFunction = null; + } + } + + for (let i = 0; i < this.subscription.data.length; i++) { + const series = this.subscription.data[i] as TbFlotSeries; + colors.push(series.dataKey.color); + const keySettings = series.dataKey.settings; + series.dataKey.tooltipValueFormatFunction = tooltipValueFormatFunction; + if (keySettings.tooltipValueFormatter && keySettings.tooltipValueFormatter.length) { + try { + series.dataKey.tooltipValueFormatFunction = new Function('value', + keySettings.tooltipValueFormatter) as TooltipValueFormatFunction; + } catch (e) { + series.dataKey.tooltipValueFormatFunction = tooltipValueFormatFunction; + } + } + series.lines = { + fill: keySettings.fillLines === true + }; + if (this.chartType === 'line' || this.chartType === 'state') { + series.lines.show = keySettings.showLines !== false; + } else { + series.lines.show = keySettings.showLines === true; + } + if (isDefined(keySettings.lineWidth) && keySettings.lineWidth !== null) { + series.lines.lineWidth = keySettings.lineWidth; + } + series.points = { + show: false, + radius: 8 + }; + if (keySettings.showPoints === true) { + series.points.show = true; + series.points.lineWidth = 5; + series.points.radius = 3; + } + if (this.chartType === 'line' && this.settings.smoothLines && !series.points.show) { + series.curvedLines = { + apply: true + }; + } + + const lineColor = tinycolor(series.dataKey.color); + lineColor.setAlpha(.75); + + series.highlightColor = lineColor.toRgbString(); + + if (this.yaxis) { + const units = series.dataKey.units && series.dataKey.units.length ? series.dataKey.units : this.trackUnits; + let yaxis: TbFlotAxisOptions; + if (keySettings.showSeparateAxis) { + yaxis = this.createYAxis(keySettings, units); + this.yaxes.push(yaxis); + } else { + yaxis = yaxesMap[units]; + if (!yaxis) { + yaxis = this.createYAxis(keySettings, units); + yaxesMap[units] = yaxis; + this.yaxes.push(yaxis); + } + } + series.yaxisIndex = this.yaxes.indexOf(yaxis); + series.yaxis = series.yaxisIndex + 1; + yaxis.keysInfo[i] = {hidden: false}; + yaxis.hidden = false; + } + } + this.options.colors = colors; + this.options.yaxes = deepClone(this.yaxes); + if (this.chartType === 'line' || this.chartType === 'bar' || this.chartType === 'state') { + if (this.chartType === 'bar') { + if (this.subscription.timeWindowConfig.aggregation && + this.subscription.timeWindowConfig.aggregation.type === AggregationType.NONE) { + this.options.series.bars.barWidth = this.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } + } + this.options.xaxis.min = this.subscription.timeWindow.minTime; + this.options.xaxis.max = this.subscription.timeWindow.maxTime; + } + + this.checkMouseEvents(); + + if (this.plot) { + this.plot.destroy(); + } + if (this.chartType === 'pie' && this.animatedPie) { + this.pieDataAnimationDuration = 250; + this.pieData = deepClone(this.subscription.data); + this.pieRenderedData = []; + this.pieTargetData = []; + for (let i = 0; i < this.subscription.data.length; i++) { + this.pieTargetData[i] = (this.subscription.data[i].data && this.subscription.data[i].data[0]) + ? this.subscription.data[i].data[0][1] : 0; + } + this.pieDataRendered(); + } + this.plotInited = true; + this.createPlot(); + } + + public update() { + if (this.updateTimeoutHandle) { + clearTimeout(this.updateTimeoutHandle); + this.updateTimeoutHandle = null; + } + if (this.subscription) { + if (!this.isMouseInteraction && this.plot) { + if (this.chartType === 'line' || this.chartType === 'bar' || this.chartType === 'state') { + + let axisVisibilityChanged = false; + if (this.yaxis) { + for (let i = 0; i < this.subscription.data.length; i++) { + const series = this.subscription.data[i] as TbFlotSeries; + const yaxisIndex = series.yaxisIndex; + if (this.yaxes[yaxisIndex].keysInfo[i].hidden !== series.dataKey.hidden) { + this.yaxes[yaxisIndex].keysInfo[i].hidden = series.dataKey.hidden; + axisVisibilityChanged = true; + } + } + if (axisVisibilityChanged) { + this.options.yaxes.length = 0; + this.yaxes.forEach((yaxis) => { + let hidden = true; + yaxis.keysInfo.forEach((info) => { + if (info) { + hidden = hidden && info.hidden; + } + }); + yaxis.hidden = hidden; + let newIndex = 1; + if (!yaxis.hidden) { + this.options.yaxes.push(yaxis); + newIndex = this.options.yaxes.length; + } + for (let k = 0; k < yaxis.keysInfo.length; k++) { + if (yaxis.keysInfo[k]) { + (this.subscription.data[k] as TbFlotSeries).yaxis = newIndex; + } + } + + }); + this.options.yaxis = { + show: this.options.yaxes.length ? true : false + }; + } + } + + this.options.xaxis.min = this.subscription.timeWindow.minTime; + this.options.xaxis.max = this.subscription.timeWindow.maxTime; + if (this.chartType === 'bar') { + if (this.subscription.timeWindowConfig.aggregation && + this.subscription.timeWindowConfig.aggregation.type === AggregationType.NONE) { + this.options.series.bars.barWidth = this.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } + } + + if (axisVisibilityChanged) { + this.redrawPlot(); + } else { + this.plot.getOptions().xaxes[0].min = this.subscription.timeWindow.minTime; + this.plot.getOptions().xaxes[0].max = this.subscription.timeWindow.maxTime; + if (this.chartType === 'bar') { + if (this.subscription.timeWindowConfig.aggregation && + this.subscription.timeWindowConfig.aggregation.type === AggregationType.NONE) { + this.plot.getOptions().series.bars.barWidth = this.defaultBarWidth; + } else { + this.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } + } + this.updateData(); + } + } else if (this.chartType === 'pie') { + if (this.animatedPie) { + this.nextPieDataAnimation(true); + } else { + this.updateData(); + } + } + } else if (this.isMouseInteraction && this.plot) { + this.updateTimeoutHandle = setTimeout(this.update.bind(this), 30); + } + } + } + + public resize() { + if (this.resizeTimeoutHandle) { + clearTimeout(this.resizeTimeoutHandle); + this.resizeTimeoutHandle = null; + } + if (this.plot && this.plotInited) { + const width = this.$element.width(); + const height = this.$element.height(); + if (width && height) { + this.plot.resize(); + if (this.chartType !== 'pie') { + this.plot.setupGrid(); + } + this.plot.draw(); + } else { + this.resizeTimeoutHandle = setTimeout(this.resize.bind(this), 30); + } + } + } + + public checkMouseEvents() { + const enabled = !this.ctx.isMobile && !this.ctx.isEdit; + if (isUndefined(this.mouseEventsEnabled) || this.mouseEventsEnabled !== enabled) { + this.mouseEventsEnabled = enabled; + if (this.$element) { + if (enabled) { + this.enableMouseEvents(); + } else { + this.disableMouseEvents(); + } + this.redrawPlot(); + } + } + } + + public destroy() { + this.cleanup(); + if (this.plot) { + this.plot.destroy(); + this.plot = null; + this.plotInited = false; + } + } + + private cleanup() { + if (this.updateTimeoutHandle) { + clearTimeout(this.updateTimeoutHandle); + this.updateTimeoutHandle = null; + } + if (this.createPlotTimeoutHandle) { + clearTimeout(this.createPlotTimeoutHandle); + this.createPlotTimeoutHandle = null; + } + if (this.resizeTimeoutHandle) { + clearTimeout(this.resizeTimeoutHandle); + this.resizeTimeoutHandle = null; + } + } + + private createPlot() { + if (this.createPlotTimeoutHandle) { + clearTimeout(this.createPlotTimeoutHandle); + this.createPlotTimeoutHandle = null; + } + if (this.plotInited && !this.plot) { + const width = this.$element.width(); + const height = this.$element.height(); + if (width && height) { + if (this.chartType === 'pie' && this.animatedPie) { + this.plot = $.plot(this.$element, this.pieData, this.options) as JQueryPlot; + } else { + this.plot = $.plot(this.$element, this.subscription.data, this.options) as JQueryPlot; + } + } else { + this.createPlotTimeoutHandle = setTimeout(this.createPlot.bind(this), 30); + } + } + } + + private updateData() { + this.plot.setData(this.subscription.data); + if (this.chartType !== 'pie') { + this.plot.setupGrid(); + } + this.plot.draw(); + } + + private redrawPlot() { + if (this.plot && this.plotInited) { + this.plot.destroy(); + this.plot = null; + this.createPlot(); + } + } + + private createYAxis(keySettings: TbFlotKeySettings, units: string): TbFlotAxisOptions { + const yaxis = deepClone(this.yaxis); + let tickDecimals: number; + let tickSize: number; + const label = keySettings.axisTitle && keySettings.axisTitle.length ? keySettings.axisTitle : yaxis.label; + if (isNumber(keySettings.axisTickDecimals)) { + tickDecimals = keySettings.axisTickDecimals; + } else { + tickDecimals = yaxis.tickDecimals; + } + if (isNumber(keySettings.axisTickSize)) { + tickSize = keySettings.axisTickSize; + } else { + tickSize = yaxis.tickSize; + } + const position = keySettings.axisPosition && keySettings.axisPosition.length ? keySettings.axisPosition : 'left'; + const min = isDefined(keySettings.axisMin) ? keySettings.axisMin : yaxis.min; + const max = isDefined(keySettings.axisMax) ? keySettings.axisMax : yaxis.max; + yaxis.label = label; + yaxis.min = min; + yaxis.max = max; + yaxis.tickUnits = units; + yaxis.tickDecimals = tickDecimals; + yaxis.tickSize = tickSize; + if (position === 'right' && tickSize === null) { + yaxis.alignTicksWithAxis = 1; + } else { + yaxis.alignTicksWithAxis = null; + } + yaxis.position = position; + + yaxis.keysInfo = []; + + if (keySettings.axisTicksFormatter && keySettings.axisTicksFormatter.length) { + try { + yaxis.ticksFormatterFunction = new Function('value', keySettings.axisTicksFormatter) as TbFlotTicksFormatterFunction; + } catch (e) { + yaxis.ticksFormatterFunction = this.yaxis.ticksFormatterFunction; + } + } + return yaxis; + } + + private seriesInfoDiv(label: string, color: string, value: any, + units: string, trackDecimals: number, active: boolean, + percent: number, valueFormatFunction: TooltipValueFormatFunction): JQuery { + const divElement = $('
'); + divElement.css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start' + }); + const lineSpan = $(''); + lineSpan.css({ + backgroundColor: color, + width: '20px', + height: '3px', + display: 'inline-block', + verticalAlign: 'middle', + marginRight: '5px' + }); + divElement.append(lineSpan); + const labelSpan = $(`${label}:`); + labelSpan.css({ + marginRight: '10px' + }); + if (active) { + labelSpan.css({ + color: '#FFF', + fontWeight: '700' + }); + } + divElement.append(labelSpan); + let valueContent: string; + if (valueFormatFunction) { + valueContent = valueFormatFunction(value); + } else { + valueContent = this.ctx.utils.formatValue(value, trackDecimals, units); + } + if (isNumber(percent)) { + valueContent += ' (' + Math.round(percent) + ' %)'; + } + const valueSpan = $(`${valueContent}`); + valueSpan.css({ + marginLeft: 'auto', + fontWeight: '700' + }); + if (active) { + valueSpan.css({ + color: '#FFF' + }); + } + divElement.append(valueSpan); + return divElement; + } + + private seriesInfoDivFromInfo(seriesHoverInfo: TbFlotSeriesHoverInfo, seriesIndex: number): string { + const units = seriesHoverInfo.units && seriesHoverInfo.units.length ? seriesHoverInfo.units : this.trackUnits; + const decimals = isDefined(seriesHoverInfo.decimals) ? seriesHoverInfo.decimals : this.trackDecimals; + const divElement = this.seriesInfoDiv(seriesHoverInfo.label, seriesHoverInfo.color, + seriesHoverInfo.value, units, decimals, seriesHoverInfo.index === seriesIndex, null, seriesHoverInfo.tooltipValueFormatFunction); + return divElement.prop('outerHTML'); + } + + private createTooltipElement(): JQuery { + const tooltip = $('
'); + tooltip.css({ + fontSize: '12px', + fontFamily: 'Roboto', + fontWeight: '300', + lineHeight: '18px', + opacity: '1', + backgroundColor: 'rgba(0,0,0,0.7)', + color: '#D9DADB', + position: 'absolute', + display: 'none', + zIndex: '1100', + padding: '4px 10px', + borderRadius: '4px' + }).appendTo('body'); + return tooltip; + } + + private formatPieTooltip(item: TbFlotPlotItem): string { + const units = item.series.dataKey.units && item.series.dataKey.units.length ? item.series.dataKey.units : this.trackUnits; + const decimals = isDefined(item.series.dataKey.decimals) ? item.series.dataKey.decimals : this.trackDecimals; + const divElement = this.seriesInfoDiv(item.series.dataKey.label, item.series.dataKey.color, + item.datapoint[1][0][1], units, decimals, true, item.series.percent, item.series.dataKey.tooltipValueFormatFunction); + return divElement.prop('outerHTML'); + } + + private formatChartTooltip(hoverInfo: TbFlotHoverInfo, seriesIndex: number): string { + let content = ''; + const timestamp = parseInt(hoverInfo.time, 10); + const date = moment(timestamp).format('YYYY-MM-DD HH:mm:ss'); + const dateDiv = $(`
${date}
`); + dateDiv.css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '4px', + fontWeight: '700' + }); + content += dateDiv.prop('outerHTML'); + if (this.tooltipIndividual) { + const found = hoverInfo.seriesHover.find((seriesHover) => { + return seriesHover.index === seriesIndex; + }); + if (found) { + content += this.seriesInfoDivFromInfo(found, seriesIndex); + } + } else { + const seriesDiv = $('
'); + seriesDiv.css({ + display: 'flex', + flexDirection: 'row' + }); + const maxRows = 15; + const columns = Math.ceil(hoverInfo.seriesHover.length / maxRows); + let columnsContent = ''; + for (let c = 0; c < columns; c++) { + const columnDiv = $('
'); + columnDiv.css({ + display: 'flex', + flexDirection: 'column' + }); + let columnContent = ''; + for (let i = c * maxRows; i < (c + 1) * maxRows; i++) { + if (i === hoverInfo.seriesHover.length) { + break; + } + const seriesHoverInfo = hoverInfo.seriesHover[i]; + columnContent += this.seriesInfoDivFromInfo(seriesHoverInfo, seriesIndex); + } + columnDiv.html(columnContent); + if (c > 0) { + columnsContent += ''; + } + columnsContent += columnDiv.prop('outerHTML'); + } + seriesDiv.html(columnsContent); + content += seriesDiv.prop('outerHTML'); + } + return content; + } + + private formatYAxisTicks(value: number, axis?: TbFlotPlotAxis): string { + if (this.settings.yaxis && this.settings.yaxis.showLabels === false) { + return ''; + } + if (axis.options.ticksFormatterFunction) { + return axis.options.ticksFormatterFunction(value); + } + const factor = axis.options.tickDecimals ? Math.pow(10, axis.options.tickDecimals) : 1; + let formatted = '' + Math.round(value * factor) / factor; + if (isDefined(axis.options.tickDecimals) && axis.options.tickDecimals !== null) { + const decimal = formatted.indexOf('.'); + const precision = decimal === -1 ? 0 : formatted.length - decimal - 1; + if (precision < axis.options.tickDecimals) { + formatted = (precision ? formatted : formatted + '.') + ('' + factor).substr(1, axis.options.tickDecimals - precision); + } + } + if (axis.options.tickUnits) { + formatted += ' ' + axis.options.tickUnits; + } + return formatted; + } + + private enableMouseEvents() { + this.$element.css('pointer-events', ''); + this.$element.addClass('mouse-events'); + this.options.selection = { mode : 'x' }; + this.$element.bind('plothover', this.flotHoverHandler); + this.$element.bind('plotselected', this.flotSelectHandler); + this.$element.bind('dblclick', this.dblclickHandler); + this.$element.bind('mousedown', this.mousedownHandler); + this.$element.bind('mouseup', this.mouseupHandler); + this.$element.bind('mouseleave', this.mouseleaveHandler); + } + + private disableMouseEvents() { + this.$element.css('pointer-events', 'none'); + this.$element.removeClass('mouse-events'); + this.options.selection = { mode : null }; + this.$element.unbind('plothover', this.flotHoverHandler); + this.$element.unbind('plotselected', this.flotSelectHandler); + this.$element.unbind('dblclick', this.dblclickHandler); + this.$element.unbind('mousedown', this.mousedownHandler); + this.$element.unbind('mouseup', this.mouseupHandler); + this.$element.unbind('mouseleave', this.mouseleaveHandler); + } + + private onFlotHover(e: any, pos: JQueryPlotPoint, item: TbFlotPlotItem) { + if (!this.plot) { + return; + } + if (!this.tooltipIndividual || item) { + const multipleModeTooltip = !this.tooltipIndividual; + if (multipleModeTooltip) { + this.plot.unhighlight(); + } + const pageX = pos.pageX; + const pageY = pos.pageY; + + let tooltipHtml; + let hoverInfo: TbFlotHoverInfo; + + if (this.chartType === 'pie') { + tooltipHtml = this.formatPieTooltip(item); + } else { + hoverInfo = this.getHoverInfo(this.plot.getData(), pos); + if (isNumber(hoverInfo.time)) { + hoverInfo.seriesHover.sort((a, b) => { + return b.value - a.value; + }); + tooltipHtml = this.formatChartTooltip(hoverInfo, item ? item.seriesIndex : -1); + } + } + if (tooltipHtml) { + this.tooltip.html(tooltipHtml) + .css({top: 0, left: 0}) + .fadeIn(200); + + const windowWidth = $( window ).width(); + const windowHeight = $( window ).height(); + const tooltipWidth = this.tooltip.width(); + const tooltipHeight = this.tooltip.height(); + let left = pageX + 5; + let top = pageY + 5; + if (windowWidth - pageX < tooltipWidth + 50) { + left = pageX - tooltipWidth - 10; + } + if (windowHeight - pageY < tooltipHeight + 20) { + top = pageY - tooltipHeight - 10; + } + this.tooltip.css({ + top, + left + }); + if (multipleModeTooltip) { + hoverInfo.seriesHover.forEach((seriesHoverInfo) => { + this.plot.highlight(seriesHoverInfo.index, seriesHoverInfo.hoverIndex); + }); + } + } + } else { + this.tooltip.stop(true); + this.tooltip.hide(); + this.plot.unhighlight(); + } + } + + private onFlotSelect(e: any, ranges: JQueryPlotSelectionRanges) { + if (!this.plot) { + return; + } + this.plot.clearSelection(); + this.subscription.onUpdateTimewindow(ranges.xaxis.from, ranges.xaxis.to); + } + + private onFlotDblClick() { + this.subscription.onResetTimewindow(); + } + + private onFlotMouseDown() { + this.isMouseInteraction = true; + } + + private onFlotMouseUp() { + this.isMouseInteraction = false; + } + + private onFlotMouseLeave() { + if (!this.tooltip) { + return; + } + this.tooltip.stop(true); + this.tooltip.hide(); + if (this.plot) { + this.plot.unhighlight(); + } + this.isMouseInteraction = false; + } + + private getHoverInfo(seriesList: TbFlotPlotDataSeries[], pos: JQueryPlotPoint): TbFlotHoverInfo { + let i: number; + let series: TbFlotPlotDataSeries; + let hoverIndex: number; + let hoverDistance: number; + let minDistance: number; + let pointTime: any; + let minTime: any; + let value: any; + let lastValue: any; + const results: TbFlotHoverInfo = { + seriesHover: [] + }; + for (i = 0; i < seriesList.length; i++) { + series = seriesList[i]; + hoverIndex = this.findHoverIndexFromData(pos.x, series); + if (series.data[hoverIndex] && series.data[hoverIndex][0]) { + hoverDistance = pos.x - series.data[hoverIndex][0]; + pointTime = series.data[hoverIndex][0]; + + if (!minDistance + || (hoverDistance >= 0 && (hoverDistance < minDistance || minDistance < 0)) + || (hoverDistance < 0 && hoverDistance > minDistance)) { + minDistance = hoverDistance; + minTime = pointTime; + } + if (series.stack) { + if (this.tooltipIndividual || !this.tooltipCumulative) { + value = series.data[hoverIndex][1]; + } else { + lastValue += series.data[hoverIndex][1]; + value = lastValue; + } + } else { + value = series.data[hoverIndex][1]; + } + if (series.stack || (series.curvedLines && series.curvedLines.apply)) { + hoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex); + } + results.seriesHover.push({ + value, + hoverIndex, + color: series.dataKey.color, + label: series.dataKey.label, + units: series.dataKey.units, + decimals: series.dataKey.decimals, + tooltipValueFormatFunction: series.dataKey.tooltipValueFormatFunction, + time: pointTime, + distance: hoverDistance, + index: i + }); + } + } + results.time = minTime; + return results; + } + + private findHoverIndexFromData(posX: number, series: TbFlotPlotDataSeries): number { + let lower = 0; + let upper = series.data.length - 1; + let middle: number; + const index: number = null; + while (index === null) { + if (lower > upper) { + return Math.max(upper, 0); + } + middle = Math.floor((lower + upper) / 2); + if (series.data[middle][0] === posX) { + return middle; + } else if (series.data[middle][0] < posX) { + lower = middle + 1; + } else { + upper = middle - 1; + } + } + } + + private findHoverIndexFromDataPoints(posX: number, series: TbFlotPlotDataSeries, last: number): number { + const ps = series.datapoints.pointsize; + const initial = last * ps; + const len = series.datapoints.points.length; + let j: number; + for (j = initial; j < len; j += ps) { + if ((!series.lines.steps && series.datapoints.points[initial] != null && series.datapoints.points[j] == null) + || series.datapoints.points[j] > posX) { + return Math.max(j - ps, 0) / ps; + } + } + return j / ps - 1; + } + + pieDataRendered() { + for (let i = 0; i < this.pieTargetData.length; i++) { + const value = this.pieTargetData[i] ? this.pieTargetData[i] : 0; + this.pieRenderedData[i] = value; + if (!this.pieData[i].data[0]) { + this.pieData[i].data[0] = [0, 0]; + } + this.pieData[i].data[0][1] = value; + } + } + + nextPieDataAnimation(start) { + if (start) { + this.finishPieDataAnimation(); + this.pieAnimationStartTime = this.pieAnimationLastTime = Date.now(); + for (let i = 0; i < this.subscription.data.length; i++) { + this.pieTargetData[i] = (this.subscription.data[i].data && this.subscription.data[i].data[0]) + ? this.subscription.data[i].data[0][1] : 0; + } + } + if (this.pieAnimationCaf) { + this.pieAnimationCaf(); + this.pieAnimationCaf = null; + } + this.pieAnimationCaf = this.ctx.$scope.raf.raf(this.onPieDataAnimation.bind(this)); + } + + onPieDataAnimation() { + const time = Date.now(); + const elapsed = time - this.pieAnimationLastTime; // this.pieAnimationStartTime; + const progress = (time - this.pieAnimationStartTime) / this.pieDataAnimationDuration; + if (progress >= 1) { + this.finishPieDataAnimation(); + } else { + if (elapsed >= 40) { + for (let i = 0; i < this.pieTargetData.length; i++) { + const prevValue = this.pieRenderedData[i]; + const targetValue = this.pieTargetData[i]; + const value = prevValue + (targetValue - prevValue) * progress; + if (!this.pieData[i].data[0]) { + this.pieData[i].data[0] = [0, 0]; + } + this.pieData[i].data[0][1] = value; + } + this.plot.setData(this.pieData); + this.plot.draw(); + this.pieAnimationLastTime = time; + } + this.nextPieDataAnimation(false); + } + } + + private finishPieDataAnimation() { + this.pieDataRendered(); + this.plot.setData(this.pieData); + this.plot.draw(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index 764fea65e8..aa7682bf23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -36,6 +36,7 @@ import { WidgetComponentsModule } from '@home/components/widget/widget-component import { WINDOW } from '@core/services/window.service'; import * as tinycolor from 'tinycolor2'; +import { TbFlot } from './lib/flot-widget'; // declare var jQuery: any; @@ -63,6 +64,8 @@ export class WidgetComponentService { this.window.tinycolor = tinycolor; // @ts-ignore this.window.cssjs = cssjs; + // @ts-ignore + this.window.TbFlot = TbFlot; this.cssParser.testMode = false; this.init(); diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.html b/ui-ngx/src/app/modules/home/components/widget/widget.component.html index 4d071ba34d..03a58007b0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.html @@ -16,11 +16,21 @@ -->
- + +
- + +
Widget Error: {{ widgetErrorData.name + ": " + widgetErrorData.message}} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 44d56727c4..961ef5a99d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -28,7 +28,8 @@ import { SimpleChanges, ViewChild, ViewContainerRef, - ViewEncapsulation + ViewEncapsulation, + ChangeDetectorRef } from '@angular/core'; import { DashboardWidget, IDashboardComponent } from '@home/models/dashboard-component.models'; import { @@ -157,7 +158,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private dashboardService: DashboardService, private datasourceService: DatasourceService, private utils: UtilsService, - private raf: RafService) { + private raf: RafService, + private cd: ChangeDetectorRef) { super(store); } @@ -290,7 +292,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } }; this.widgetContext.utils = { - formatValue: this.formatValue + formatValue: this.formatValue.bind(this) }; this.widgetContext.actionsApi = { actionDescriptorsBySourceId, @@ -325,6 +327,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI alarmService: this.alarmService, datasourceService: this.datasourceService, utils: this.utils, + raf: this.raf, widgetUtils: this.widgetContext.utils, dashboardTimewindowApi: { onResetTimewindow: this.dashboard.onResetTimewindow.bind(this.dashboard), @@ -407,6 +410,13 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } } + public onLegendKeyHiddenChange(index: number) { + for (const id of Object.keys(this.widgetContext.subscriptions)) { + const subscription = this.widgetContext.subscriptions[id]; + subscription.updateDataVisibility(index); + } + } + private loadFromWidgetInfo() { const widgetNamespace = `widget-type-${(this.widget.isSystemType ? 'sys-' : '')}${this.widget.bundleAlias}-${this.widget.typeAlias}`; const elem = this.elementRef.nativeElement; @@ -464,11 +474,17 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } if (!this.widgetContext.inited && this.isReady()) { this.widgetContext.inited = true; - try { - this.widgetTypeInstance.onInit(); - } catch (e) { - this.handleWidgetException(e); + if (this.cafs.init) { + this.cafs.init(); + this.cafs.init = null; } + this.cafs.init = this.raf.raf(() => { + try { + this.widgetTypeInstance.onInit(); + } catch (e) { + this.handleWidgetException(e); + } + }); if (!this.typeParameters.useCustomDatasources && this.widgetContext.defaultSubscription) { this.widgetContext.defaultSubscription.subscribe(); } @@ -570,6 +586,15 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } )); + this.rxSubscriptions.push(this.dashboard.dashboardTimewindowChanged.subscribe( + (dashboardTimewindow) => { + for (const id of Object.keys(this.widgetContext.subscriptions)) { + const subscription = this.widgetContext.subscriptions[id]; + subscription.onDashboardTimewindowChanged(dashboardTimewindow); + } + } + )); + this.configureDynamicWidgetComponent(); if (!this.typeParameters.useCustomDatasources) { // this.cre @@ -722,12 +747,17 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI dataLoading: (subscription) => { if (this.loadingData !== subscription.loadingData) { this.loadingData = subscription.loadingData; + this.cd.detectChanges(); } }, - legendDataUpdated: (subscription) => { + legendDataUpdated: (subscription, detectChanges) => { + if (detectChanges) { + this.cd.detectChanges(); + } }, timeWindowUpdated: (subscription, timeWindowConfig) => { this.widget.config.timewindow = timeWindowConfig; + this.cd.detectChanges(); } }; diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index c1a1dc8cea..d1be047ae4 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -47,9 +47,10 @@ export interface IDashboardComponent { isMobileSize: boolean; autofillHeight: boolean; dashboardTimewindow: Timewindow; + dashboardTimewindowChanged: Observable; aliasController: IAliasController; stateController: IStateController; - onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval: number): void; + onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval?: number): void; onResetTimewindow(): void; } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 089ee571f4..cb8c8605ea 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -29,7 +29,7 @@ import { WidgetTypeDescriptor, WidgetTypeParameters } from '@shared/models/widget.models'; -import { Timewindow } from '@shared/models/time/time.models'; +import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; import { EntityInfo, IAliasController, @@ -43,6 +43,7 @@ import { } from '@core/api/widget-api.models'; import { ComponentFactory } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; +import { RafService } from '@core/services/raf.service'; export interface IWidgetAction { name: string; @@ -93,7 +94,7 @@ export interface WidgetContext { datasources?: Array; data?: Array; hiddenData?: Array<{data: DataSet}>; - timeWindow?: Timewindow; + timeWindow?: WidgetTimewindow; } export interface IDynamicWidgetComponent { @@ -103,6 +104,7 @@ export interface IDynamicWidgetComponent { rpcEnabled: boolean; rpcErrorText: string; rpcRejection: HttpErrorResponse; + raf: RafService; [key: string]: any; } diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 49e650cdcd..baacc18006 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -158,18 +158,25 @@ export class TelemetryPluginCmdsWrapper { } } -export interface SubscriptionUpdateMsg { +export interface SubscriptionData { + [key: string]: [number, any][]; +} + +export interface SubscriptionDataHolder { + data: SubscriptionData; +} + +export interface SubscriptionUpdateMsg extends SubscriptionDataHolder { subscriptionId: number; errorCode: number; errorMsg: string; - data: {[key: string]: [number, string][]}; } export class SubscriptionUpdate implements SubscriptionUpdateMsg { subscriptionId: number; errorCode: number; errorMsg: string; - data: {[key: string]: [number, string][]}; + data: SubscriptionData; constructor(msg: SubscriptionUpdateMsg) { this.subscriptionId = msg.subscriptionId; diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index 98af5fbf15..fec2262e0e 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -21,6 +21,7 @@ export const SECOND = 1000; export const MINUTE = 60 * SECOND; export const HOUR = 60 * MINUTE; export const DAY = 24 * HOUR; +export const YEAR = DAY * 365; export enum TimewindowType { REALTIME, @@ -68,6 +69,7 @@ export const aggregationTranslations = new Map( ); export interface Aggregation { + interval?: number; type: AggregationType; limit: number; } @@ -78,6 +80,25 @@ export interface Timewindow { realtime?: IntervalWindow; history?: HistoryWindow; aggregation?: Aggregation; +} + +export interface SubscriptionAggregation extends Aggregation { + interval?: number; + timeWindow?: number; + stateData?: boolean; +} + +export interface SubscriptionTimewindow { + startTs?: number; + realtimeWindowMs?: number; + fixedWindow?: FixedWindow; + aggregation?: SubscriptionAggregation; +} + +export interface WidgetTimewindow { + minTime?: number; + maxTime?: number; + interval?: number; stDiff?: number; } @@ -91,7 +112,7 @@ export function historyInterval(timewindowMs: number): Timewindow { } export function defaultTimewindow(timeService: TimeService): Timewindow { - const currentTime = new Date().getTime(); + const currentTime = Date.now(); const timewindow: Timewindow = { displayValue: '', selectedTab: TimewindowType.REALTIME, @@ -183,6 +204,67 @@ export function toHistoryTimewindow(timewindow: Timewindow, startTimeMs: number, return historyTimewindow; } +export function createSubscriptionTimewindow(timewindow: Timewindow, stDiff: number, stateData: boolean, + timeService: TimeService): SubscriptionTimewindow { + const subscriptionTimewindow: SubscriptionTimewindow = { + fixedWindow: null, + realtimeWindowMs: null, + aggregation: { + interval: SECOND, + limit: timeService.getMaxDatapointsLimit(), + type: AggregationType.AVG + } + }; + let aggTimewindow = 0; + if (stateData) { + subscriptionTimewindow.aggregation.type = AggregationType.NONE; + subscriptionTimewindow.aggregation.stateData = true; + } + if (isDefined(timewindow.aggregation) && !stateData) { + subscriptionTimewindow.aggregation = { + type: timewindow.aggregation.type || AggregationType.AVG, + limit: timewindow.aggregation.limit || timeService.getMaxDatapointsLimit() + }; + } + if (isDefined(timewindow.realtime)) { + subscriptionTimewindow.realtimeWindowMs = timewindow.realtime.timewindowMs; + subscriptionTimewindow.aggregation.interval = + timeService.boundIntervalToTimewindow(subscriptionTimewindow.realtimeWindowMs, timewindow.realtime.interval, + subscriptionTimewindow.aggregation.type); + subscriptionTimewindow.startTs = Date.now() + stDiff - subscriptionTimewindow.realtimeWindowMs; + const startDiff = subscriptionTimewindow.startTs % subscriptionTimewindow.aggregation.interval; + aggTimewindow = subscriptionTimewindow.realtimeWindowMs; + if (startDiff) { + subscriptionTimewindow.startTs -= startDiff; + aggTimewindow += subscriptionTimewindow.aggregation.interval; + } + } else if (isDefined(timewindow.history)) { + if (isDefined(timewindow.history.timewindowMs)) { + const currentTime = Date.now(); + subscriptionTimewindow.fixedWindow = { + startTimeMs: currentTime - timewindow.history.timewindowMs, + endTimeMs: currentTime + }; + aggTimewindow = timewindow.history.timewindowMs; + } else { + subscriptionTimewindow.fixedWindow = { + startTimeMs: timewindow.history.fixedTimewindow.startTimeMs, + endTimeMs: timewindow.history.fixedTimewindow.endTimeMs + }; + aggTimewindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + } + subscriptionTimewindow.startTs = subscriptionTimewindow.fixedWindow.startTimeMs; + subscriptionTimewindow.aggregation.interval = + timeService.boundIntervalToTimewindow(aggTimewindow, timewindow.history.interval, subscriptionTimewindow.aggregation.type); + } + const aggregation = subscriptionTimewindow.aggregation; + aggregation.timeWindow = aggTimewindow; + if (aggregation.type !== AggregationType.NONE) { + aggregation.limit = Math.ceil(aggTimewindow / subscriptionTimewindow.aggregation.interval); + } + return subscriptionTimewindow; +} + export function cloneSelectedTimewindow(timewindow: Timewindow): Timewindow { const cloned: Timewindow = {}; if (isDefined(timewindow.selectedTab)) { diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index b3e911b298..f57e0e52e4 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -221,16 +221,21 @@ export interface Datasource { entityId?: string; entityName?: string; entityAliasId?: string; + unresolvedStateEntity?: boolean; + dataReceived?: boolean; [key: string]: any; // TODO: } export type DataSet = [number, any][]; -export interface DatasourceData { +export interface DataSetHolder { + data: DataSet; +} + +export interface DatasourceData extends DataSetHolder { datasource: Datasource; dataKey: DataKey; - data: DataSet; } export interface LegendKey { @@ -239,10 +244,10 @@ export interface LegendKey { } export interface LegendKeyData { - min: number; - max: number; - avg: number; - total: number; + min: string; + max: string; + avg: string; + total: string; hidden: boolean; } @@ -340,3 +345,13 @@ export interface Widget { col: number; config: WidgetConfig; } + +export interface JsonSettingsSchema { + schema?: { + type: string; + title: string; + properties: {[key: string]: any}; + required?: string[]; + }; + form?: any[]; +} diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 1b5d25bafe..e99c4270ee 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -209,6 +209,10 @@ label { } } +.tb-noselect { + user-select: none; +} + div { &.tb-small { font-size: 14px; diff --git a/ui-ngx/src/tsconfig.app.json b/ui-ngx/src/tsconfig.app.json index f74d460ff1..44125beebb 100644 --- a/ui-ngx/src/tsconfig.app.json +++ b/ui-ngx/src/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", - "types": ["node", "jquery"] + "types": ["node", "jquery", "flot", "tinycolor2"] }, "exclude": [ "test.ts", diff --git a/ui-ngx/src/typings/jquery.flot.typings.d.ts b/ui-ngx/src/typings/jquery.flot.typings.d.ts new file mode 100644 index 0000000000..2c84383241 --- /dev/null +++ b/ui-ngx/src/typings/jquery.flot.typings.d.ts @@ -0,0 +1,128 @@ +/// +/// Copyright © 2016-2019 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. +/// + + +interface JQueryPlot extends jquery.flot.plot { + destroy(): void; + highlight(series: jquery.flot.dataSeries | number, datapoint: jquery.flot.item | number): void; + clearSelection(): void; +} + +interface JQueryPlotPoint extends jquery.flot.point { + pageX: number; + pageY: number; +} + +interface JQueryPlotDataSeries extends jquery.flot.dataSeries, JQueryPlotSeriesOptions { + datapoints?: jquery.flot.datapoints; +} + +interface JQueryPlotOptions extends jquery.flot.plotOptions { + title?: string; + subtitile?: string; + shadowSize?: number; + HtmlText?: boolean; + selection?: JQueryPlotSelection; + xaxis?: JQueryPlotAxisOptions; + series?: JQueryPlotSeriesOptions; + crosshair?: JQueryPlotCrosshairOptions; +} + +interface JQueryPlotAxisOptions extends jquery.flot.axisOptions { + label?: string; + labelFont?: any; +} + +interface JQueryPlotAxis extends jquery.flot.axis, JQueryPlotAxisOptions { + options: JQueryPlotAxisOptions; +} + +interface JQueryPlotSeriesOptions extends jquery.flot.seriesOptions { + stack?: boolean; + curvedLines?: JQueryPlotCurvedLinesOptions; + pie?: JQueryPlotPieOptions; +} + +declare type JQueryPlotCrosshairMode = 'x' | 'y' | 'xy' | null; + +interface JQueryPlotCrosshairOptions { + mode?: JQueryPlotCrosshairMode; + color?: string; + lineWidth?: number; +} + +interface JQueryPlotCurvedLinesOptions { + active?: boolean; + apply?: boolean; + monotonicFit?: boolean; + tension?: number; + nrSplinePoints?: number; + legacyOverride?: any; +} + +interface JQueryPlotPieOptions { + show?: boolean; + radius?: any; + innerRadius?: any; + startAngle?: number; + tilt?: number; + offset?: { + top?: number; + left?: number; + }; + stroke?: { + color?: string; + width?: number; + }; + shadow?: { + top?: number; + left?: number; + alpha?: number; + }; + label?: { + show?: boolean; + formatter?: (label: string, slice?: any) => string; + radius?: any; + background?: { + color?: string; + opacity?: number; + }; + threshold?: number; + }; + combine?: { + threshold?: number; + color?: string; + label?: string; + }; + highlight?: number; +} + +declare type JQueryPlotSelectionMode = 'x' | 'y' | 'xy' | null; +declare type JQueryPlotSelectionShape = 'round' | 'mitter' | 'bevel'; + +interface JQueryPlotSelection { + mode?: JQueryPlotSelectionMode; + color?: string; + shape?: JQueryPlotSelectionShape; + minSize?: number; +} + +interface JQueryPlotSelectionRanges { + [axis: string]: { + from: number; + to: number; + }; +} diff --git a/ui-ngx/src/typings.d.ts b/ui-ngx/src/typings/jquery.typings.d.ts similarity index 100% rename from ui-ngx/src/typings.d.ts rename to ui-ngx/src/typings/jquery.typings.d.ts diff --git a/ui-ngx/tsconfig.json b/ui-ngx/tsconfig.json index 4d36a53659..02a68043b0 100644 --- a/ui-ngx/tsconfig.json +++ b/ui-ngx/tsconfig.json @@ -13,7 +13,8 @@ "target": "es5", "typeRoots": [ "node_modules/@types", - "src/typings.d.ts" + "src/typings/jquery.typings.d.ts", + "src/typings/jquery.flot.typings.d.ts" ], "paths": { "@app/*": ["src/app/*"],