diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 9d14f51feb..4293eb4c5c 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -32,3 +32,4 @@ Mihaela Aleksandrova Kevin Lin Sam Sulaimanov Igor Melnyk +Wouter Voorberg diff --git a/docker-compose.yml b/docker-compose.yml index 779b2e8707..7691d52230 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: volumes: - postgresql-data:/var/lib/postgresql/data - manager-data:/storage + ports: + - "5432:5432" keycloak: restart: always diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java index dc38a60c54..b7826decf1 100644 --- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java +++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java @@ -16,7 +16,7 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery { public Formula formula; public enum Formula { - MIN, AVG, MAX + MIN, AVG, MAX, DELTA } public AssetDatapointIntervalQuery() { @@ -42,9 +42,21 @@ public String getSQLQuery(String tableName, Class attributeType) throws Illeg boolean isBoolean = Boolean.class.isAssignableFrom(attributeType); String function = (gapFill ? "public.time_bucket_gapfill" : "public.time_bucket"); if (isNumber) { - return "select " + function + "(cast(? as interval), timestamp) AS x, " + this.formula.toString().toLowerCase() + "(cast(value as numeric)) FROM " + tableName + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC"; + if (this.formula == Formula.DELTA) { + this.gapFill = true; //check where this is set in using this code + //Returns the delta between the start and end of the period. Ex. for interval 1 minute, 10:41 will hold the difference between 10:41:00 and 10:42:00. + return "WITH interval_data AS (" + "SELECT " + function + "(cast(? as interval), timestamp) AS x, public.locf(public.last(value::DOUBLE PRECISION, timestamp)) AS numeric_value " + + "FROM " + tableName + " " + "WHERE ENTITY_ID = ? AND ATTRIBUTE_NAME = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? GROUP BY x ) SELECT x, COALESCE(numeric_value - LAG(numeric_value, 1, numeric_value) OVER (ORDER BY x), numeric_value) AS delta FROM interval_data ORDER BY x ASC"; + + } else { + return "select " + function + "(cast(? as interval), timestamp) AS x, " + this.formula.toString().toLowerCase() + "(cast(value as numeric)) FROM " + tableName + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC"; + } } else if (isBoolean) { - return "select " + function + "(cast(? as interval), timestamp) AS x, " + this.formula.toString().toLowerCase() + "(case when cast(cast(value as text) as boolean) is true then 1 else 0 end) FROM " + tableName + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC"; + if (this.formula == Formula.DELTA) { + throw new IllegalStateException("Query of type DELTA is not applicable for boolean attributes."); + } else { + return "select " + function + "(cast(? as interval), timestamp) AS x, " + this.formula.toString().toLowerCase() + "(case when cast(cast(value as text) as boolean) is true then 1 else 0 end) FROM " + tableName + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC"; + } } else { throw new IllegalStateException("Query of type Interval requires either a number or a boolean attribute."); } diff --git a/package.json b/package.json index 3d60ac7f7a..faedca3cde 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,8 @@ ], "devDependencies": { "@openremote/util": "workspace:*" + }, + "dependencies": { + "@openremote/or-attribute-report": "workspace:^" } } diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index f6e37cdb64..15c11dbfee 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -170,6 +170,14 @@ "dataSampling": "Data sampling", "algorithm": "Algorithm", "algorithmMethod": "Method per interval", + "methodMaxAttributes": "Maximum", + "methodMinAttributes": "Minimum", + "methodAvgAttributes": "Average", + "methodDeltaAttributes": "Difference", + "methodMedianAttributes": "Median", + "methodModeAttributes": "Mode", + "methodSumAttributes": "Sum", + "methodCountAttributes": "Count", "addAttribute": "Add attribute", "selectAttributes": "Select attributes", "selectAttribute": "Select attribute", @@ -762,6 +770,9 @@ "showLegend": "Show legend", "showGeoJson": "Show GeoJSON overlay", "showValueAs": "Show value as", + "defaultStacked": "Stack by default", + "showToolBox": "Show Toolbox", + "isChart": "Bar chart instead of table", "customLabel": "Custom label", "customTimeSpan": "Custom", "userCanEdit": "Allow user input", @@ -773,7 +784,9 @@ "zoom": "Zoom", "center": "Center", "imageUrl": "Image URL", - "noImageSelected": "No image selected" + "noImageSelected": "No image selected", + "noData": "No data", + "noDataOrMethod": "No data or no method per interval selected for selected attributes" }, "south": "South", "east": "East", @@ -858,6 +871,11 @@ "AVG": "Average (mean)", "MIN": "Minimum", "MAX": "Maximum", + "DELTA":"Difference", + "COUNT": "Count", + "MEDIAN": "Median", + "MODE": "Mode", + "SUM": "Sum", "alarm": { "": "Alarm", "alarm_plural": "Alarms", diff --git a/ui/component/or-app/tsconfig.json b/ui/component/or-app/tsconfig.json index 99e2258fe7..d4a7e7bbbf 100644 --- a/ui/component/or-app/tsconfig.json +++ b/ui/component/or-app/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../../component/or-map" }, { "path": "../../component/or-attribute-input" }, { "path": "../../component/or-attribute-picker" }, + { "path": "../../component/or-attribute-report" }, { "path": "../../component/or-asset-tree" }, { "path": "../../component/or-asset-viewer" }, { "path": "../../component/or-data-viewer" }, diff --git a/ui/component/or-attribute-report/.npmignore b/ui/component/or-attribute-report/.npmignore new file mode 100644 index 0000000000..e4d5a449c8 --- /dev/null +++ b/ui/component/or-attribute-report/.npmignore @@ -0,0 +1,8 @@ +build/ +build.gradle +test/ +src/ +webpack.config.js +tsconfig.json +dist/*.map +*.tsbuildinfo diff --git a/ui/component/or-attribute-report/README.md b/ui/component/or-attribute-report/README.md new file mode 100644 index 0000000000..9d96a420f2 --- /dev/null +++ b/ui/component/or-attribute-report/README.md @@ -0,0 +1,31 @@ +# @openremote/or-attribute-report \ +[![NPM Version][npm-image]][npm-url] +[![Linux Build][travis-image]][travis-url] +[![Test Coverage][coveralls-image]][coveralls-url] + +Web Component for showing calculated values at intervals of an attribute. + +## Install +```bash +npm i @openremote/or-attribute-report +yarn add @openremote/or-attribute-report +``` + +## Usage +For a full list of properties, methods and options refer to the TypeDoc generated [documentation](). + + +## Supported Browsers +The last 2 versions of all modern browsers are supported, including Chrome, Safari, Opera, Firefox, Edge. In addition, +Internet Explorer 11 is also supported. + + +## License +[GNU AGPL](https://www.gnu.org/licenses/agpl-3.0.en.html) + +[npm-image]: https://img.shields.io/npm/v/live-xxx.svg +[npm-url]: https://npmjs.org/package/@openremote/or-attribute-report +[travis-image]: https://img.shields.io/travis/live-js/live-xxx/master.svg +[travis-url]: https://travis-ci.org/live-js/live-xxx +[coveralls-image]: https://img.shields.io/coveralls/live-js/live-xxx/master.svg +[coveralls-url]: https://coveralls.io/r/live-js/live-xxx?branch=master diff --git a/ui/component/or-attribute-report/build.gradle b/ui/component/or-attribute-report/build.gradle new file mode 100644 index 0000000000..98cdcb8e26 --- /dev/null +++ b/ui/component/or-attribute-report/build.gradle @@ -0,0 +1,15 @@ +buildDir = "dist" + +task clean() { + doLast { + delete "dist" + } +} + +task prepareUi() { + dependsOn clean, npmPrepare +} + +task publishUi() { + dependsOn clean, npmPublish +} \ No newline at end of file diff --git a/ui/component/or-attribute-report/package.json b/ui/component/or-attribute-report/package.json new file mode 100644 index 0000000000..e34983ece2 --- /dev/null +++ b/ui/component/or-attribute-report/package.json @@ -0,0 +1,42 @@ +{ + "name": "@openremote/or-attribute-report", + "version": "1.3.3", + "description": "OpenRemote attribute report", + "main": "dist/umd/index.bundle.js", + "module": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./*": "./lib/*.js" + }, + "types": "lib/index.d.ts", + "scripts": { + "test": "echo \"No tests\" && exit 0", + "prepack": "npx webpack" + }, + "author": "OpenRemote", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@material/data-table": "^9.0.0", + "@material/dialog": "^9.0.0", + "@openremote/core": "workspace:*", + "@openremote/or-asset-tree": "workspace:*", + "@openremote/or-attribute-picker": "workspace:*", + "@openremote/or-components": "workspace:*", + "@openremote/or-icon": "workspace:*", + "@openremote/or-mwc-components": "workspace:*", + "@openremote/or-translate": "workspace:*", + "chart.js": "^3.6.0", + "echarts": "^5.6.0", + "jsonpath-plus": "^6.0.1", + "lit": "^2.0.2", + "moment": "^2.29.4" + }, + "devDependencies": { + "@openremote/util": "workspace:*", + "@types/chart.js": "^2.9.34", + "@types/offscreencanvas": "^2019.6.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/ui/component/or-attribute-report/src/index.ts b/ui/component/or-attribute-report/src/index.ts new file mode 100644 index 0000000000..54cf0a2b52 --- /dev/null +++ b/ui/component/or-attribute-report/src/index.ts @@ -0,0 +1,1369 @@ +import {css, html, LitElement, PropertyValues, TemplateResult, unsafeCSS} from "lit"; +import {customElement, property, query} from "lit/decorators.js"; +import i18next from "i18next"; +import {translate} from "@openremote/or-translate"; +import { + Asset, + AssetDatapointIntervalQueryFormula, + AssetDatapointQueryUnion, + AssetEvent, + AssetModelUtil, + AssetQuery, + Attribute, + AttributeRef, + DatapointInterval, + ReadAssetEvent, + ValueDatapoint +} from "@openremote/model"; +import manager, {DefaultColor2, DefaultColor3, DefaultColor4, DefaultColor5, Util} from "@openremote/core"; +import "@openremote/or-asset-tree"; +import "@openremote/or-mwc-components/or-mwc-input"; +import "@openremote/or-components/or-panel"; +import "@openremote/or-translate"; +import {ECharts, EChartsOption, init} from "echarts"; +import {MDCDataTable} from "@material/data-table"; +import {TableColumn, TableRow, TableConfig} from "@openremote/or-mwc-components/or-mwc-table"; +import {InputType, OrMwcInput} from "@openremote/or-mwc-components/or-mwc-input"; +import "@openremote/or-components/or-loading-indicator"; +import moment from "moment"; +import {OrAssetTreeSelectionEvent} from "@openremote/or-asset-tree"; +import {getAssetDescriptorIconTemplate} from "@openremote/or-icon"; +import {GenericAxiosResponse, isAxiosError} from "@openremote/rest"; +import {OrAttributePicker, OrAttributePickerPickedEvent} from "@openremote/or-attribute-picker"; +import {OrMwcDialog, showDialog} from "@openremote/or-mwc-components/or-mwc-dialog"; +import {cache} from "lit/directives/cache.js"; +import {throttle} from "lodash"; +import {getContentWithMenuTemplate} from "@openremote/or-mwc-components/or-mwc-menu"; +import {ListItem} from "@openremote/or-mwc-components/or-mwc-list"; +import {when} from "lit/directives/when.js"; +import {createRef, Ref, ref } from "lit/directives/ref.js"; + +export class OrAttributeReportEvent extends CustomEvent { + + public static readonly NAME = "or-report-event"; + + constructor(value?: any, previousValue?: any) { + super(OrAttributeReportEvent.NAME, { + detail: { + value: value, + previousValue: previousValue + }, + bubbles: true, + composed: true + }); + } +} + +export interface AttributeReportViewConfig { + attributeRefs?: AttributeRef[]; + fromTimestamp?: number; + toTimestamp?: number; + /*compareOffset?: number;*/ + period?: moment.unitOfTime.Base; + deltaFormat?: "absolute" | "percentage"; + decimals?: number; +} + +export interface OrAttributeReportEventDetail { + value?: any; + previousValue?: any; +} + +declare global { + export interface HTMLElementEventMap { + [OrAttributeReportEvent.NAME]: OrAttributeReportEvent; + } +} + +export interface AttributeReportConfig { + xLabel?: string; + yLabel?: string; +} + +export interface OrAttributeReportConfig { + report?: AttributeReportConfig; + realm?: string; + views: {[name: string]: { + [panelName: string]: AttributeReportViewConfig + }}; +} + +// Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) +declare function require(name: string): any; + +// TODO: Add webpack/rollup to build so consumers aren't forced to use the same tooling +const dialogStyle = require("@material/dialog/dist/mdc.dialog.css"); +const tableStyle = require("@material/data-table/dist/mdc.data-table.css"); + +// language=CSS +const style = css` + :host { + + --internal-or-chart-background-color: var(--or-chart-background-color, var(--or-app-color2, ${unsafeCSS(DefaultColor2)})); + --internal-or-chart-text-color: var(--or-chart-text-color, var(--or-app-color3, ${unsafeCSS(DefaultColor3)})); + --internal-or-chart-controls-margin: var(--or-chart-controls-margin, 0 0 20px 0); + --internal-or-chart-controls-margin-children: var(--or-chart-controls-margin-children, 0 auto 20px auto); + --internal-or-chart-graph-fill-color: var(--or-chart-graph-fill-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); + --internal-or-chart-graph-fill-opacity: var(--or-chart-graph-fill-opacity, 0.25); + --internal-or-chart-graph-line-color: var(--or-chart-graph-line-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); + --internal-or-chart-graph-point-color: var(--or-chart-graph-point-color, var(--or-app-color3, ${unsafeCSS(DefaultColor3)})); + --internal-or-chart-graph-point-border-color: var(--or-chart-graph-point-border-color, var(--or-app-color5, ${unsafeCSS(DefaultColor5)})); + --internal-or-chart-graph-point-radius: var(--or-chart-graph-point-radius, 4); + --internal-or-chart-graph-point-hit-radius: var(--or-chart-graph-point-hit-radius, 20); + --internal-or-chart-graph-point-border-width: var(--or-chart-graph-point-border-width, 2); + --internal-or-chart-graph-point-hover-color: var(--or-chart-graph-point-hover-color, var(--or-app-color5, ${unsafeCSS(DefaultColor5)})); + --internal-or-chart-graph-point-hover-border-color: var(--or-chart-graph-point-hover-border-color, var(--or-app-color3, ${unsafeCSS(DefaultColor3)})); + --internal-or-chart-graph-point-hover-radius: var(--or-chart-graph-point-hover-radius, 4); + --internal-or-chart-graph-point-hover-border-width: var(--or-chart-graph-point-hover-border-width, 2); + + width: 100%; + display: block; + } + + .line-label { + border-width: 1px; + border-color: var(--or-app-color3); + margin-right: 5px; + } + + .line-label.solid { + border-style: solid; + } + + .line-label.dashed { + background-image: linear-gradient(to bottom, var(--or-app-color3) 50%, white 50%); + width: 2px; + border: none; + background-size: 10px 16px; + background-repeat: repeat-y; + } + + .button-icon { + align-self: center; + padding: 10px; + cursor: pointer; + } + + a { + display: flex; + cursor: pointer; + text-decoration: underline; + font-weight: bold; + color: var(--or-app-color1); + --or-icon-width: 12px; + } + + .mdc-dialog .mdc-dialog__surface { + min-width: 600px; + height: calc(100vh - 50%); + } + + :host([hidden]) { + display: none; + } + + #container { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + height: 100%; + } + + #msg { + height: 100%; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; + } + + #msg:not([hidden]) { + display: flex; + } + .period-controls { + display: flex; + min-width: 180px; + align-items: center; + } + + #controls { + display: flex; + flex-wrap: wrap; + margin: var(--internal-or-chart-controls-margin); + width: 100%; + flex-direction: row; + margin: 0; + border-top: 3px solid var(--or-app-color2); + justify-content: center; + } + + #attribute-list { + overflow: hidden auto; + min-height: 50px; + max-height: 120px; + width: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + .attribute-list-dense { + flex-wrap: wrap; + } + + .attribute-list-item { + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + padding: 0; + min-height: 50px; + } + .attribute-list-item-dense { + min-height: 28px; + } + + .button-clear { + background: none; + visibility: hidden; + color: ${unsafeCSS(DefaultColor5)}; + --or-icon-fill: ${unsafeCSS(DefaultColor5)}; + display: inline-block; + border: none; + padding: 0; + cursor: pointer; + } + + .attribute-list-item:hover .button-clear { + visibility: visible; + } + + .button-clear:hover { + --or-icon-fill: var(--or-app-color4); + } + + + .attribute-list-item-label { + display: flex; + flex: 1 1 0; + line-height: 16px; + flex-direction: column; + } + + .attribute-list-item-bullet { + width: 12px; + height: 12px; + border-radius: 7px; + margin-right: 10px; + } + + .attribute-list-item .button.delete { + display: none; + } + + .attribute-list-item:hover .button.delete { + display: block; + } + + #controls > * { + margin-top: 5px; + margin-bottom: 5px; + justify-content: center; + } + + .dialog-container { + display: flex; + flex-direction: row; + flex: 1 1 0; + } + + .dialog-container > * { + flex: 1 1 0; + } + + .dialog-container > or-mwc-input { + background-color: var(--or-app-color2); + border-left: 3px solid var(--or-app-color4); + } + + #chart-container { + flex: 1 1 0; + position: relative; + overflow: hidden; + width: 100%; + /*min-height: 400px; + max-height: 550px;*/ + } + #chart-controls { + display: flex; + flex-direction: column; + align-items: center; + } + #chart { + width: 100% !important; + height: 100%; !important; + } + + #table-container { + height: 100%; + max-width: 100%; + overflow: hidden; + flex: 1 1 0; + } + + #table { + width: 100%; + margin-bottom: 10px; + } + + #table > table { + width: 100%; + table-layout: fixed; + } + + #table th, #table td { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + + @media screen and (max-width: 1280px) { + #chart-container { + } + } + + @media screen and (max-width: 769px) { + .mdc-dialog .mdc-dialog__surface { + min-width: auto; + + max-width: calc(100vw - 32px); + max-height: calc(100% - 32px); + } + + #container { + flex-direction: column; + } + + #controls { + min-width: 100%; + padding-left: 0; + } + .interval-controls, + .period-controls { + flex-direction: row; + justify-content: left; + align-items: center; + gap: 8px; + } + } +`; + +@customElement("or-attribute-report") +export class OrAttributeReport extends translate(i18next)(LitElement) { + + public static DEFAULT_TIMESTAMP_FORMAT = "L HH:mm:ss"; + + static get styles() { + return [ + css`${unsafeCSS(tableStyle)}`, + css`${unsafeCSS(dialogStyle)}`, + style + ]; + } + + @property({type: Object}) + public assets: Asset[] = []; + + @property({type: Object}) + private activeAsset?: Asset; + + @property({type: Object}) + public assetAttributes: [number, Attribute][] = []; + + @property({type: Array}) + public colorPickedAttributes: Array<{ attributeRef: AttributeRef; color: string }> = []; + + @property({type: Object}) + public attributeSettings = { + rightAxisAttributes: [] as AttributeRef[], + methodMaxAttributes:[] as AttributeRef[], + methodMinAttributes: [] as AttributeRef[], + methodAvgAttributes: [] as AttributeRef[], + methodDeltaAttributes: [] as AttributeRef[], + methodMedianAttributes: [] as AttributeRef[], + methodModeAttributes: [] as AttributeRef[], + methodSumAttributes: [] as AttributeRef[], + methodCountAttributes: [] as AttributeRef[] + }; + + @property({type: Array}) + public colors: string[] = ["#3869B1", "#DA7E30", "#3F9852", "#CC2428", "#6B4C9A", "#922427", "#958C3D", "#535055"]; + + @property({type: Object}) + public readonly datapointQuery!: AssetDatapointQueryUnion; + + @property({type: Object}) + public config?: OrAttributeReportConfig; + + @property({type: Object}) + public chartOptions?: any + public chartSettings: { + showLegend: boolean; + showToolBox: boolean; + defaultStacked: boolean; + } = { + showLegend: true, + showToolBox: true, + defaultStacked: false, + }; + + @property({type: String}) + public realm?: string; + + @property() + public panelName?: string; + + @property() + public attributeControls: boolean = true; + + @property() + public timeframe?: [Date, Date]; + + @property() + public timestampControls: boolean = true; + + @property() + protected timePrefixOptions?: string[]; + + @property() + public timeWindowOptions?: Map; + + @property() + public timePrefixKey?: string; + + @property() + public timeWindowKey?: string; + + @property() + public isCustomWindow?: boolean = false; + + @property() + public denseLegend: boolean = false; + + @property() + public isChart: boolean = false; + + @property() + public decimals: number = 2; + + @property() + protected _loading: boolean = false; + + @property() + protected _data?: any[]; + + @property() + protected _tableTemplate?: TemplateResult; + + @query("#chart") + protected _chartElem!: HTMLDivElement; + @query("#table") + protected _tableElem!: HTMLDivElement; + protected _table?: MDCDataTable; + protected columns: TableColumn[] = []; + protected rows: TableRow[] = []; + protected _chartOptions: EChartsOption = {}; + protected _chart?: ECharts; + protected _style!: CSSStyleDeclaration; + protected _startOfPeriod?: number; + protected _endOfPeriod?: number; + protected _latestError?: string; + protected _dataAbortController?: AbortController; + + protected _resizeHandler?: any; + protected _containerResizeObserver?: ResizeObserver; + + constructor() { + super(); + this.addEventListener(OrAssetTreeSelectionEvent.NAME, this._onTreeSelectionChanged); + } + + connectedCallback() { + super.connectedCallback(); + this._style = window.getComputedStyle(this); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this._cleanup(); + + } + + firstUpdated() { + this.loadSettings(false); + } + + updated(changedProperties: PropertyValues) { + + super.updated(changedProperties); + + if (changedProperties.has("realm")) { + if(changedProperties.get("realm") != undefined) { // Checking whether it was undefined previously, to prevent loading 2 times and resetting attribute properties. + this.assets = []; + this.loadSettings(true); + } + } + + const reloadData = changedProperties.has('colorPickedAttributes') || changedProperties.has("datapointQuery") || changedProperties.has("timeframe") || changedProperties.has("timePrefixKey") || changedProperties.has("timeWindowKey")|| + changedProperties.has("attributeSettings") || changedProperties.has("assetAttributes") || changedProperties.has("realm"); + + if (reloadData) { + this._data = undefined; + if (this._chart) { + // Remove event listeners + this._toggleChartEventListeners(false); + this._chart.dispose(); + this._chart = undefined; + } + this._loadData(); + } + + if (!this._data) { + return; + } + + if (!this._chart && this.isChart) { + this._chartOptions = { + //animation: false, + grid: { + show: true, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-tree-background-color"), + borderColor: this._style.getPropertyValue("--internal-or-chart-text-color"), + left: 25,//'5%', // 5% padding + right: 25,//'5%', + top: this.chartSettings.showToolBox ? 28 : 10, + bottom: 25, //55 + containLabel: true + }, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-tree-background-color"), + tooltip: { + trigger: 'axis', + confine: true, //make tooltip not go outside frame bounds + axisPointer: { + type: 'shadow' + }, + }, + //legend: this.chartSettings.showLegend ? {show: true} : undefined, + toolbox: this.chartSettings.showToolBox ? {show:true, feature: {magicType: {type: ['bar', 'stack']}}} : undefined, + xAxis: { + type: 'category', + axisLine: { + lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")} + }, + splitLine: {show: true}, + axisTick: {alignWithLabel: true}, + axisLabel: { + hideOverlap: true, + rotate: 25, + interval: 0, + fontSize: 10 + } + }, + yAxis: [ + { + type: 'value', + axisLine: { lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")}}, + boundaryGap: ['10%', '10%'], + scale: true, + min: this.chartOptions.options.scales.y.min ? this.chartOptions.options.scales.y.min : undefined, + max: this.chartOptions.options.scales.y.max ? this.chartOptions.options.scales.y.max : undefined + }, + { + type: 'value', + show: this.attributeSettings.rightAxisAttributes.length > 0, + axisLine: { lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")}}, + boundaryGap: ['10%', '10%'], + scale: true, + min: this.chartOptions.options.scales.y1.min ? this.chartOptions.options.scales.y1.min : undefined, + max: this.chartOptions.options.scales.y1.max ? this.chartOptions.options.scales.y1.max : undefined + } + ], + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100 + } + ], + series: [], + }; + + // Add toolbox if enabled + if(this.chartSettings.showToolBox) { + this._chartOptions!.toolbox! = { + right: 45, + top: 0, + feature: { + dataView: {readOnly: true}, + magicType: { + type: ['stack'] + }, + saveAsImage: {} + } + } + } + + // Initialize echarts instance + this._chart = init(this._chartElem); + // Set chart options to default + this._chart.setOption(this._chartOptions); + this._toggleChartEventListeners(true); + } + + if (changedProperties.has("_data")) { + //Update chart to data from set period + if (this._chart) { + this._updateChartData(); + } + } + + this.onCompleted().then(() => { + this.dispatchEvent(new OrAttributeReportEvent('rendered')); + }); + + } + + // Not the best implementation, but it changes the legend & controls to wrap under the chart. + // Also sorts the attribute lists horizontally when it is below the chart + applyChartResponsiveness(): void { + if(this.shadowRoot) { + const container = this.shadowRoot.getElementById('container'); + if(container) { + const bottomLegend: boolean = (container.clientWidth < 2000); // CHANGE THIS + container.style.flexDirection = bottomLegend ? 'column' : 'row'; + const periodControls = this.shadowRoot.querySelector('.period-controls') as HTMLElement; + if(periodControls) { + periodControls.style.justifyContent = bottomLegend ? 'center' : 'space-between'; + periodControls.style.paddingLeft = bottomLegend ? '' : '18px'; + } + const attributeList = this.shadowRoot.getElementById('attribute-list'); + if(attributeList) { + attributeList.style.gap = bottomLegend ? '4px 12px' : ''; + attributeList.style.maxHeight = bottomLegend ? '90px' : ''; + attributeList.style.flexFlow = bottomLegend ? 'row wrap' : 'column nowrap'; + attributeList.style.padding = bottomLegend ? '0' : '12px 0'; + } + this.shadowRoot.querySelectorAll('.attribute-list-item').forEach((item: Element) => { + (item as HTMLElement).style.minHeight = bottomLegend ? '0px' : '44px'; + (item as HTMLElement).style.paddingLeft = bottomLegend ? '' : '16px'; + (item.children[1] as HTMLElement).style.flexDirection = bottomLegend ? 'row' : 'column'; + (item.children[1] as HTMLElement).style.gap = bottomLegend ? '4px' : ''; + }); + } + } + } + + render() { + const disabled = !this.isChart || this._loading || this._latestError; + + const tableConfig: any = { + fullHeight: true + } as TableConfig + + return html` + + + +
+ ${when(this._loading, () => html` +
+ +
+ `)} + ${when(this._latestError, () => html` +
+ +
+ `)} + ${when(this._data?.every(entry => entry.data.length === 0), () => html` +
+ +
+ `)} + ${when(this.isChart, () => html` +
+
+
+ + `, () => html ` +
+ + `)} + + + + + ${(this.timestampControls || this.attributeControls || this.chartSettings.showLegend) ? html` +
+ ${cache(this.chartSettings.showLegend ? html` +
+ ${this.assetAttributes == null || this.assetAttributes.length == 0 ? html` +
+ ${i18next.t('noAttributesConnected')} +
+ ` : undefined} + ${this.assetAttributes && this.assetAttributes.map(([assetIndex, attr], index) => { + const asset: Asset | undefined = this.assets[assetIndex]; + const colourIndex = index % this.colors.length; + const color = this.colorPickedAttributes.find(({ attributeRef }) => attributeRef.name === attr.name && attributeRef.id === asset.id)?.color; + const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset!.type, attr.name, attr); + const label = Util.getAttributeLabel(attr, descriptors[0], asset!.type, true); + const axisNote = (this.attributeSettings.rightAxisAttributes.find(ar => asset!.id === ar.id && attr.name === ar.name)) ? i18next.t('right') : undefined; + const bgColor = ( color ?? this.colors[colourIndex] ) || ""; + //Find which aggregation methods are active + const methodList: { data: string | undefined; }[] = Object.entries(this.attributeSettings) + .filter(([key]) => key.includes('method')) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, attributeRefs]) => { + const isActive = attributeRefs.some( + (ref: AttributeRef) => + ref.id === asset!.id && ref.name === attr.name + ); + return { + data: isActive ? ` (${i18next.t(key)})` : undefined, + }; + }); + + + + return html` +
+ ${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(this.assets[assetIndex]!.type!), undefined, undefined, bgColor.split('#')[1])} +
+
+ ${this.assets[assetIndex].name} + ${when(axisNote, () => html`(${axisNote})`)} +
+ ${label} ${methodList.map(item => item.data)} +
+
+ ` + })} +
+ ` : undefined)} +
+
+ ${this.timePrefixKey && this.timePrefixOptions && this.timeWindowKey && this.timeWindowOptions ? html` + ${this.timestampControls ? html` + + ${getContentWithMenuTemplate( + html``, + this.timePrefixOptions.map((option) => ({ value: option } as ListItem)), + this.timePrefixKey, + (value: string | string[]) => { + this.timeframe = undefined; // remove any custom start & end times + this.timePrefixKey = value.toString(); + }, + undefined, + undefined, + undefined, + true + )} + + ${getContentWithMenuTemplate( + html``, + Array.from(this.timeWindowOptions!.keys()).map((key) => ({ value: key } as ListItem)), + this.timeWindowKey, + (value: string | string[]) => { + this.timeframe = undefined; // remove any custom start & end times + this.timeWindowKey = value.toString(); + }, + undefined, + undefined, + undefined, + true + )} + + + + + + + ` : html` + + `} + ` : undefined} +
+
+ + + + + + + + + + + + + +
${i18next.t('from')}:${moment(this.timeframe?.[0] ?? this._startOfPeriod).format("lll")}
${i18next.t('to')}:${moment(this.timeframe?.[1] ?? this._endOfPeriod).format("lll")}
+
+ ${this.attributeControls ? html` + + ` : undefined} +
+ +
+ ` : undefined} +
+ `; + } + + protected async _onTreeSelectionChanged(event: OrAssetTreeSelectionEvent) { + // Need to fully load the asset + if (!manager.events) { + return; + } + + const selectedNode = event.detail && event.detail.newNodes.length > 0 ? event.detail.newNodes[0] : undefined; + + if (!selectedNode) { + this.activeAsset = undefined; + } else { + // fully load the asset + const assetEvent: AssetEvent = await manager.events.sendEventWithReply({ + eventType: "read-asset", + assetId: selectedNode.asset!.id + } as ReadAssetEvent); + this.activeAsset = assetEvent.asset; + } + } + + + + + async loadSettings(reset: boolean) { + + if(this.assetAttributes == undefined || reset) { + this.assetAttributes = []; + } + + if (!this.realm) { + this.realm = manager.getRealm(); + } + + if (!this.timePrefixOptions) { + this.timePrefixOptions = this._getDefaultTimePrefixOptions(); + } + + if (!this.timeWindowOptions) { + this.timeWindowOptions = this._getDefaultTimeWindowOptions(); + } + + if (!this.timeWindowKey) { + this.timeWindowKey = this.timeWindowOptions.keys().next().value.toString(); + } + + if (!this.timePrefixKey) { + this.timePrefixKey = this.timePrefixOptions[1]; + } + + if (!this.panelName) { + return; + } + + const viewSelector = window.location.hash; + const allConfigs: OrAttributeReportConfig[] = await manager.console.retrieveData("OrChartConfig") || []; + + if (!Array.isArray(allConfigs)) { + manager.console.storeData("OrChartConfig", [allConfigs]); + } + + let config: OrAttributeReportConfig | undefined = allConfigs.find(e => e.realm === this.realm); + + if (!config) { + return; + } + + const view = config.views && config.views[viewSelector] ? config.views[viewSelector][this.panelName] : undefined; + + if (!view) { + return; + } + + if (!view.attributeRefs) { + // Old/invalid config format remove it + delete config.views[viewSelector][this.panelName]; + const cleanData = [...allConfigs.filter(e => e.realm !== this.realm), config]; + manager.console.storeData("OrChartConfig", cleanData); + return; + } + + const assetIds = view.attributeRefs.map((attrRef) => attrRef.id!); + + if (assetIds.length === 0) { + return; + } + + this._loading = true; + + if (!assetIds.every(id => !!this.assets.find(asset => asset.id === id))) { + const query = { + ids: assetIds + } as AssetQuery; + + try { + const response = await manager.rest.api.AssetResource.queryAssets(query); + const assets = response.data || []; + view.attributeRefs = view.attributeRefs.filter((attrRef) => !!assets.find((asset) => asset.id === attrRef.id && asset.attributes && asset.attributes.hasOwnProperty(attrRef.name!))); + + manager.console.storeData("OrChartConfig", [...allConfigs.filter(e => e.realm !== this.realm), config]); + this.assets = assets.filter((asset) => view.attributeRefs!.find((attrRef) => attrRef.id === asset.id)); + } catch (e) { + console.error("Failed to get assets requested in settings", e); + } + + this._loading = false; + + if (this.assets && this.assets.length > 0) { + this.assetAttributes = view.attributeRefs.map((attrRef) => { + const assetIndex = this.assets.findIndex((asset) => asset.id === attrRef.id); + const asset = assetIndex >= 0 ? this.assets[assetIndex] : undefined; + return asset && asset.attributes ? [assetIndex!, asset.attributes[attrRef.name!]] : undefined; + }).filter((indexAndAttr) => !!indexAndAttr) as [number, Attribute][]; + } + } + } + + async saveSettings() { + if (!this.panelName) { + return; + } + + const viewSelector = window.location.hash; + const allConfigs: OrAttributeReportConfig[] = await manager.console.retrieveData("OrChartConfig") || []; + let config: OrAttributeReportConfig | undefined = allConfigs.find(e => e.realm === this.realm); + + if (!config) { + config = { + realm: this.realm, + views: { + } + } + } + + if (!config.views[viewSelector]) { + config.views[viewSelector] = {}; + } + + if (!this.assets || !this.assetAttributes || this.assets.length === 0 || this.assetAttributes.length === 0) { + delete config.views[viewSelector][this.panelName]; + } else { + config.realm = this.realm; + config.views[viewSelector][this.panelName] = { + attributeRefs: this.assetAttributes.map(([index, attr]) => { + const asset = this.assets[index]; + return !!asset ? {id: asset.id, name: attr.name} as AttributeRef : undefined; + }).filter((attrRef) => !!attrRef) as AttributeRef[], + }; + } + + manager.console.storeData("OrChartConfig", [...allConfigs.filter(e => e.realm !== this.realm), config]); + } + + protected _openDialog() { + const dialog = showDialog(new OrAttributePicker() + .setShowOnlyDatapointAttrs(true) + .setMultiSelect(true) + .setSelectedAttributes(this._getSelectedAttributes())); + + dialog.addEventListener(OrAttributePickerPickedEvent.NAME, (ev: any) => this._addAttribute(ev.detail)); + } + + protected _openTimeDialog(startTimestamp?: number, endTimestamp?: number) { + this.isCustomWindow = true; + const startRef: Ref = createRef(); + const endRef: Ref = createRef(); + const dialog = showDialog(new OrMwcDialog() + .setHeading(i18next.t('timeframe')) + .setContent(() => html` +
+ + +
+ `) + .setActions([{ + actionName: "cancel", + content: "cancel" + }, { + actionName: "ok", + content: "ok", + action: () => { + if(startRef.value?.value && endRef.value?.value) { + this.timeframe = [new Date(startRef.value.value), new Date(endRef.value.value)]; + } + } + }]) + ) + } + + + protected async _addAttribute(selectedAttrs?: AttributeRef[]) { + if (!selectedAttrs) return; + + this.assetAttributes = []; + for (const attrRef of selectedAttrs) { + const response = await manager.rest.api.AssetResource.get(attrRef.id!); + this.activeAsset = response.data; + if (this.activeAsset) { + let assetIndex = this.assets.findIndex((asset) => asset.id === attrRef.id); + if (assetIndex < 0) { + assetIndex = this.assets.length; + this.assets = [...this.assets, this.activeAsset]; + } + this.assetAttributes.push([assetIndex, attrRef]); + } + } + this.assetAttributes = [...this.assetAttributes]; + this.saveSettings(); + } + + protected _getSelectedAttributes() { + return this.assetAttributes.map(([assetIndex, attr]) => { + return {id: this.assets[assetIndex].id, name: attr.name}; + }); + } + + async onCompleted() { + await this.updateComplete; + } + + protected _cleanup() { + if (this._chart) { + //('cleanup found _chart exists so disposing'); + this._toggleChartEventListeners(false); + this._chart.dispose(); + this._chart = undefined; + this.requestUpdate(); + } + } + + protected _deleteAttribute (index: number) { + const removed = this.assetAttributes.splice(index, 1)[0]; + const assetIndex = removed[0]; + this.assetAttributes = [...this.assetAttributes]; + if (!this.assetAttributes.some(([index, attrRef]) => index === assetIndex)) { + // Asset no longer referenced + this.assets.splice(index, 1); + this.assetAttributes.forEach((indexRef) => { + if (indexRef[0] >= assetIndex) { + indexRef[0] -= 1; + } + }); + } + this.saveSettings(); + } + + + + + protected _getDefaultTimePrefixOptions(): string[] { + return ["this", "last"]; + } + + + protected _getDefaultTimeWindowOptions(): Map { + return new Map([ + ["hour", ['hours', 1]], + ["6Hours", ['hours', 6]], + ["24Hours", ['hours', 24]], + ["day", ['days', 1]], + ["7Days", ['days', 7]], + ["week", ['weeks', 1]], + ["30Days", ['days', 30]], + ["month", ['months', 1]], + ["365Days", ['days', 365]], + ["year", ['years', 1]] + ]); + }; + + + protected _getTimeSelectionDates(timePrefixSelected: string, timeWindowSelected: string): [Date, Date] { + let startDate = moment(); + let endDate = moment(); + + const timeWindow: [moment.unitOfTime.DurationConstructor, number] | undefined = this.timeWindowOptions!.get(timeWindowSelected); + + if (!timeWindow) { + throw new Error(`Unsupported time window selected: ${timeWindowSelected}`); + } + + const [unit , value]: [moment.unitOfTime.DurationConstructor, number] = timeWindow; + + switch (timePrefixSelected) { + case "this": + if (value == 1) { // For singulars like this hour + startDate = moment().startOf(unit); + endDate = moment().endOf(unit); + } else { // For multiples like this 5 min, put now in the middle + startDate = moment().subtract(value*0.5, unit); + endDate = moment().add(value*0.5, unit); + } + break; + case "last": + startDate = moment().subtract(value, unit).startOf(unit); + if (value == 1) { // For singulars like last hour + endDate = moment().startOf(unit); + } else { //For multiples like last 5 min + endDate = moment(); + } + break; + } + return [startDate.toDate(), endDate.toDate()]; + } + + + protected _shiftTimeframe(currentStart: Date, currentEnd: Date, timeWindowSelected: string, direction: string) { + const timeWindow = this.timeWindowOptions!.get(timeWindowSelected); + + if (!timeWindow) { + throw new Error(`Unsupported time window selected: ${timeWindowSelected}`); + } + + const [unit, value] = timeWindow; + let newStart = moment(currentStart); + direction === "previous" ? newStart.subtract(value, unit as moment.unitOfTime.DurationConstructor) : newStart.add(value, unit as moment.unitOfTime.DurationConstructor); + let newEnd = moment(currentEnd) + direction === "previous" ? newEnd.subtract(value, unit as moment.unitOfTime.DurationConstructor) : newEnd.add(value, unit as moment.unitOfTime.DurationConstructor); + this.timeframe = [newStart.toDate(), newEnd.toDate()]; + } + + + protected _getInterval(diffInHours: number): [number, DatapointInterval, string] { + //Returns amount of steps, interval size and moment.js time format + if(diffInHours <= 1) { + return [5, DatapointInterval.MINUTE,"h:mmA"]; + } else if(diffInHours <= 3) { + return [20, DatapointInterval.MINUTE,"h:mmA"]; + } else if(diffInHours <= 6) { + return [30, DatapointInterval.MINUTE,"h:mmA"]; + } else if(diffInHours <= 24) { // hour if up to one day + return [1, DatapointInterval.HOUR,"h:mmA"]; + } else if(diffInHours <= 48) { // hour if up to two days + return [6, DatapointInterval.HOUR,"h:mmA"]; + } else if(diffInHours <= 744) { // one day if up to one month + return [1, DatapointInterval.DAY,"ddd | MMM Do"]; + } else if(diffInHours <= 8760) { // one week if up to 1 year + return [1, DatapointInterval.WEEK,"[Week] w 'YY"]; + } else { // one month if more than a year + return [1, DatapointInterval.MONTH,"MMM 'YY"]; + } + } + + protected async _loadData() { + if ( this._data || !this.assetAttributes || !this.assets || (this.assets.length === 0) || (this.assetAttributes.length === 0) || !this.datapointQuery) { + return; + } + + if(this._loading) { + if(this._dataAbortController) { + this._dataAbortController.abort("Data request overridden"); + delete this._dataAbortController; + } else { + return; + } + } + + this._loading = true; + const dates: [Date, Date] = this._getTimeSelectionDates(this.timePrefixKey!, this.timeWindowKey!); + this._startOfPeriod = this.timeframe ? this.timeframe[0].getTime() : dates[0].getTime(); + this._endOfPeriod = this.timeframe ? this.timeframe[1].getTime() : dates[1].getTime(); + //} + const data: any = []; + let promises; + + try { + this._dataAbortController = new AbortController(); + promises = this.assetAttributes.map(async ([assetIndex, attribute], index) => { + + const asset = this.assets[assetIndex]; + const shownOnRightAxis = !!this.attributeSettings.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const color = this.colorPickedAttributes.find(({ attributeRef }) => attributeRef.name === attribute.name && attributeRef.id === asset.id)?.color; + const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset.type, attribute.name, attribute); + const label = Util.getAttributeLabel(attribute, descriptors[0], asset.type, false); + const unit = Util.resolveUnits(Util.getAttributeUnits(attribute, descriptors[0], asset.type)); + const colourIndex = index % this.colors.length; + const options = { signal: this._dataAbortController?.signal }; + + + // Map calculation methods to their corresponding attribute arrays and formulas + const methodMapping: { [key: string]: { active: boolean; formula: AssetDatapointIntervalQueryFormula } } = { + AVG: { active: !!this.attributeSettings.methodAvgAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.AVG }, + //COUNT: { active: !!this.attributeSettings.methodCountAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.COUNT }, + DELTA: { active: !!this.attributeSettings.methodDeltaAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.DELTA }, + MAX: { active: !!this.attributeSettings.methodMaxAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.MAX }, + //MEDIAN: { active: !!this.attributeSettings.methodMedianAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.MEDIAN }, + MIN: { active: !!this.attributeSettings.methodMinAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.MIN }, + //MODE: { active: !!this.attributeSettings.methodModeAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.MODE }, + //SUM: { active: !!this.attributeSettings.methodSumAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name), formula: AssetDatapointIntervalQueryFormula.SUM } + }; + + // Iterate over the mapping, make a dataset for every active method + for (const [key, value] of (Object.entries(methodMapping)).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))) { + if (value.active) { + //Initiate query Attribute Data + let dataset = await this._loadAttributeData(asset, attribute, color ?? this.colors[colourIndex], this._startOfPeriod!, this._endOfPeriod!, value.formula, asset.name + " " + label + " | " + i18next.t(value.formula), options, unit); + (dataset as any).assetId = asset.id; + (dataset as any).attrName = attribute.name; + (dataset as any).unit = unit; + (dataset as any).yAxisIndex = shownOnRightAxis ? '1' : '0'; + (dataset as any).color = color ?? this.colors[colourIndex]; + + data.push(dataset); + + } + } + + + }); + + + if(promises) { + await Promise.all(promises); + } + + this._data = data; + + //Load data into table format + if (!this.isChart && this._data![0]) { + this.columns = ['Time', ...this._data!.map(entry => entry.name)]; + + this.rows = this._data![0].data.map((subArray: any[]) => ({ + content: [subArray[0], ...this._data!.map(entry => entry.data[subArray[0] === entry.data[0][0] ? 0 : 1][1])] + })); + } + + this._loading = false; + + + } catch (ex) { + console.error(ex); + if((ex as Error)?.message === "canceled") { + return; // If request has been canceled (using AbortController); return, and prevent _loading is set to false. + } + this._loading = false; + + + if(isAxiosError(ex)) { + if(ex.message.includes("timeout")) { + this._latestError = "noAttributeDataTimeout"; + return; + } else if(ex.response?.status === 413) { + this._latestError = "datapointRequestTooLarge"; + return; + } + } + this._latestError = "errorOccurred"; + } + } + + + protected async _loadAttributeData(asset: Asset, attribute: Attribute, color: string, from: number, to: number, formula: AssetDatapointIntervalQueryFormula, label?: string, options?: any, unit?: any) { + + const dataset = { + name: label, + type: 'bar', + data: [] as [any, any][], + stack: this.chartSettings.defaultStacked ? `${formula}` : undefined, + lineStyle: { + color: color, + }, + tooltip: { + // @ts-ignore + valueFormatter: value => value + unit + }, + label: { + show: true, + align: 'left', + verticalAlign: 'middle', + position: 'top', + fontStyle: 'italic', + fontSize: 10, + rotate: '90', + distance: 15, + formatter: (params: { dataIndex: number; value: number }): string => { + // Show labels only for the first index (index 0) + return params.dataIndex === 0 ? `${formula}` : ''; //Or make it i18next.t(formula) to display longer text + }} + } + + + if (asset.id && attribute.name && this.datapointQuery) { + let response: GenericAxiosResponse[]>; + const query = JSON.parse(JSON.stringify(this.datapointQuery)); // recreating object, since the changes shouldn't apply to parent components; only or-chart itself. + + + query.fromTimestamp = this._startOfPeriod; + query.toTimestamp = this._endOfPeriod; + query.formula = formula; + const diffInHours = (this._endOfPeriod! - this._startOfPeriod!) / 1000 / 60 / 60; + const intervalArr = this._getInterval(diffInHours); + query.interval = (intervalArr[0].toString() + " " + intervalArr[1].toString()); // for example: "5 minute" + + response = await manager.rest.api.AssetDatapointResource.getDatapoints(asset.id, attribute.name, query, options); + + + let data: ValueDatapoint[] = []; + + if (response.status === 200) { + data = response.data + .filter(value => value.y !== null && value.y !== undefined) + .map(point => ({x: point.x, y: point.y} as ValueDatapoint)) + + dataset.data = data.map(point => [moment(point.x).format(intervalArr[2]), +point.y.toFixed(this.decimals)]); + } + + + + } + return dataset; + } + + + protected _updateChartData(){ + this._chart!.setOption({ + series: this._data!.map(series => ({ + ...series, + })) + }); + } + + + protected _toggleChartEventListeners(connect: boolean){ + if (connect) { + //Connect event listeners + // Make chart size responsive + //window.addEventListener("resize", () => this._chart!.resize()); + this._containerResizeObserver = new ResizeObserver(() => this._chart!.resize()); + this._containerResizeObserver.observe(this._chartElem); + // Add event listener for chart resize + this._resizeHandler = this._chart!.on('resize', throttle(() => { this.applyChartResponsiveness(); }, 200)); + } + else if (!connect) { + //Disconnect event listeners + this._chart!.off('resize', this._resizeHandler); + this._containerResizeObserver?.disconnect(); + this._containerResizeObserver = undefined; + } + + + + + + } + +} diff --git a/ui/component/or-attribute-report/tsconfig.json b/ui/component/or-attribute-report/tsconfig.json new file mode 100644 index 0000000000..4db20cbd35 --- /dev/null +++ b/ui/component/or-attribute-report/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "src", + "types": ["offscreencanvas"] + }, + "include": [ + "./src" + ], + "references": [ + { "path": "../core" }, + { "path": "../or-components" }, + { "path": "../or-icon" }, + { "path": "../or-asset-tree" }, + { "path": "../or-mwc-components" }, + { "path": "../or-attribute-picker" }, + { "path": "../or-translate" } + ] +} diff --git a/ui/component/or-attribute-report/webpack.config.js b/ui/component/or-attribute-report/webpack.config.js new file mode 100644 index 0000000000..c48ec7b800 --- /dev/null +++ b/ui/component/or-attribute-report/webpack.config.js @@ -0,0 +1,18 @@ +const util = require("@openremote/util"); + +bundles = { + "index": { + vendor: { + "chart.js": "Chart", + "jsonpath-plus": "JSONPath", + "moment": "moment" + }, + excludeOr: true + }, + "index.bundle": { + excludeOr: true, + }, + "index.orbundle": undefined +}; + +module.exports = util.generateExports(__dirname); diff --git a/ui/component/or-dashboard-builder/package.json b/ui/component/or-dashboard-builder/package.json index ecd4bb7c12..137adb9826 100644 --- a/ui/component/or-dashboard-builder/package.json +++ b/ui/component/or-dashboard-builder/package.json @@ -18,6 +18,7 @@ "dependencies": { "@openremote/core": "workspace:*", "@openremote/model": "workspace:*", + "@openremote/or-attribute-report": "workspace:*", "@openremote/or-chart": "workspace:*", "@openremote/rest": "workspace:*", "gridstack": "^7.2.0", diff --git a/ui/component/or-dashboard-builder/src/index.ts b/ui/component/or-dashboard-builder/src/index.ts index 88663df491..6612746ca3 100644 --- a/ui/component/or-dashboard-builder/src/index.ts +++ b/ui/component/or-dashboard-builder/src/index.ts @@ -31,6 +31,7 @@ import {MapWidget} from "./widgets/map-widget"; import {AttributeInputWidget} from "./widgets/attribute-input-widget"; import {TableWidget} from "./widgets/table-widget"; import {GatewayWidget} from "./widgets/gateway-widget"; +import {ReportWidget} from "./widgets/report-widget"; // language=CSS const styling = css` @@ -218,6 +219,7 @@ export function registerWidgetTypes() { widgetTypes.set("attributeinput", AttributeInputWidget.getManifest()); widgetTypes.set("table", TableWidget.getManifest()); widgetTypes.set("gateway", GatewayWidget.getManifest()); + widgetTypes.set("report", ReportWidget.getManifest()); } @customElement("or-dashboard-builder") diff --git a/ui/component/or-dashboard-builder/src/panels/attributes-panel.ts b/ui/component/or-dashboard-builder/src/panels/attributes-panel.ts index c635c3a0ab..86d5eb6381 100644 --- a/ui/component/or-dashboard-builder/src/panels/attributes-panel.ts +++ b/ui/component/or-dashboard-builder/src/panels/attributes-panel.ts @@ -55,7 +55,7 @@ export class AttributesSelectEvent extends CustomEvent<{ assets: Asset[], attrib const styling = css` #attribute-list { - overflow: auto; + overflow: visible; flex: 1 1 0; width: 100%; display: flex; @@ -66,7 +66,7 @@ const styling = css` position: relative; cursor: pointer; display: flex; - flex-direction: row; + flex-direction: column; align-items: stretch; gap: 10px; padding: 0; @@ -91,8 +91,9 @@ const styling = css` flex: 1; justify-content: end; align-items: center; - display: flex; + display: none; gap: 8px; + margin-bottom: 20px; } .attribute-list-item-bullet { @@ -124,6 +125,7 @@ const styling = css` .attribute-list-item:hover .attribute-list-item-actions { background: white; z-index: 1; + display: flex; } .attribute-list-item:hover .button-action { @@ -252,17 +254,19 @@ export class AttributesPanel extends LitElement { const label = Util.getAttributeLabel(attribute, descriptors[0], asset.type, true); return html`
-
- ${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(asset.type))} -
-
- ${when(!!this.attributeLabelCallback, +
+
+ ${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(asset.type))} +
+
+ ${when(!!this.attributeLabelCallback, () => this.attributeLabelCallback!(asset, attribute, label), () => html` - ${asset.name} - ${label} - ` - )} + ${asset.name} + ${label} + ` + ) } +
diff --git a/ui/component/or-dashboard-builder/src/settings/chart-settings.ts b/ui/component/or-dashboard-builder/src/settings/chart-settings.ts index 8da97fe582..f0c7cb4350 100644 --- a/ui/component/or-dashboard-builder/src/settings/chart-settings.ts +++ b/ui/component/or-dashboard-builder/src/settings/chart-settings.ts @@ -107,7 +107,6 @@ export class ChartSettings extends WidgetSettings {
-
${when(isMultiAxis, () => html` diff --git a/ui/component/or-dashboard-builder/src/settings/report-settings.ts b/ui/component/or-dashboard-builder/src/settings/report-settings.ts new file mode 100644 index 0000000000..7f2e3fe13d --- /dev/null +++ b/ui/component/or-dashboard-builder/src/settings/report-settings.ts @@ -0,0 +1,493 @@ +import {css, html, TemplateResult } from "lit"; +import { customElement } from "lit/decorators.js"; +import {WidgetSettings} from "../util/widget-settings"; +import "../panels/attributes-panel"; +import "../util/settings-panel"; +import {i18next} from "@openremote/or-translate"; +import {AttributeAction, AttributeActionEvent, AttributesSelectEvent} from "../panels/attributes-panel"; +import {Asset, AssetDatapointIntervalQuery, AssetDatapointIntervalQueryFormula, Attribute, AttributeRef} from "@openremote/model"; +import {ReportWidgetConfig} from "../widgets/report-widget"; +import {InputType, OrInputChangedEvent} from "@openremote/or-mwc-components/or-mwc-input"; +import {when} from "lit/directives/when.js"; +import moment from "moment/moment"; +import {ListItem, ListType, OrMwcList, OrMwcListChangedEvent} from "@openremote/or-mwc-components/or-mwc-list"; +import {showDialog, OrMwcDialog, DialogAction} from "@openremote/or-mwc-components/or-mwc-dialog"; + +const styling = css` + .switch-container { + display: flex; + align-items: center; + justify-content: space-between; + } +` + + +@customElement("report-settings") +export class ReportSettings extends WidgetSettings { + + protected readonly widgetConfig!: ReportWidgetConfig; + + + + protected timeWindowOptions: Map = new Map; + protected timePrefixOptions: string[] = []; + protected samplingOptions: Map = new Map(); + + public setTimeWindowOptions(options: Map) { + this.timeWindowOptions = options; + } + + public setTimePrefixOptions(options: string[]) { + this.timePrefixOptions = options; + } + + public setSamplingOptions(options: Map) { + this.samplingOptions = options; + } + + static get styles() { + return [...super.styles, styling]; + } + + protected render(): TemplateResult { + const attributeFilter: (attr: Attribute) => boolean = (attr): boolean => { + return ["boolean", "positiveInteger", "positiveNumber", "number", "long", "integer", "bigInteger", "negativeInteger", "negativeNumber", "bigNumber", "integerByte", "direction"].includes(attr.type!) + }; + const attrSettings = this.widgetConfig.attributeSettings; + const min = this.widgetConfig.chartOptions.options?.scales?.y?.min; + const max = this.widgetConfig.chartOptions.options?.scales?.y?.max; + const isMultiAxis = attrSettings.rightAxisAttributes.length > 0; + const attributeLabelCallback = (asset: Asset, attribute: Attribute, attributeLabel: string) => { + const isOnRightAxis = isMultiAxis && attrSettings.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + //Find which calculation methods are active + const methodList = Object.entries(this.widgetConfig.attributeSettings) + .filter(([key]) => key.includes('method')) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .reduce((activeKeys, [key, attributeRefs]) => { + const isActive = attributeRefs.some( + (ref: AttributeRef) => ref.id === asset?.id && ref.name === attribute.name + ); + if (isActive) { + activeKeys.push(i18next.t(key)); + } + return activeKeys; + }, [] as any[]); + + return html` + ${asset.name} + ${attributeLabel} + ${when(isOnRightAxis, () => html` + + `)} + + ` + } + + + const attributeActionCallback = (attributeRef: AttributeRef): AttributeAction[] => { + return [ + { + icon: 'palette', + tooltip: i18next.t('dashboard.lineColor'), + disabled: false + }, + { + icon: 'calculator-variant-outline', + tooltip: i18next.t('algorithmMethod'), + disabled: false + }, + { + icon: this.widgetConfig.attributeSettings.rightAxisAttributes.includes(attributeRef) ? "arrow-right-bold" : "arrow-left-bold", + tooltip: i18next.t('dashboard.toggleAxis'), + disabled: false + }, + { + icon: 'mdi-blank', + tooltip: '', + disabled: true + } + ] + } + return html` +
+ + + + + + + +
+ +
+ + +
+ +
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + + ${when(this.widgetConfig.isChart, () => html` + +
+ + +
+ ${when(isMultiAxis, () => html` +
+ +
+ `)} +
+ ${max !== undefined ? html` + + ` : html` + + `} + +
+
+ ${min !== undefined ? html` + + ` : html` + + `} + +
+
+ + + ${when(isMultiAxis, () => { + const rightMin = this.widgetConfig.chartOptions.options?.scales?.y1?.min; + const rightMax = this.widgetConfig.chartOptions.options?.scales?.y1?.max; + return html` +
+
+ +
+
+ ${rightMax !== undefined ? html` + + ` : html` + + `} + +
+
+ ${rightMin !== undefined ? html` + + ` : html` + + `} + +
+
+ ` + }) } +
+
+ `)} +
+ `; + } + + // Check which icon was pressed and act accordingly. + protected onAttributeAction(ev: AttributeActionEvent) { + const { asset ,attributeRef, action } = ev.detail; + + const findAttributeIndex = (array: AttributeRef[], ref: AttributeRef) => { + return array.findIndex(item => item.id === ref.id && item.name === ref.name); + }; + + switch (action.icon) { + case "palette": // Change color + this.openColorPickDialog(attributeRef); + break; + case "arrow-right-bold": + case "arrow-left-bold": + this.toggleAttributeSetting("rightAxisAttributes", attributeRef); + break; + case "calculator-variant-outline": + this.openAlgorithmMethodsDialog(attributeRef); + break; + default: + console.warn('Unknown attribute panel action:', action); + } + } + + // When the list of attributeRefs is changed by the asset selector, + // we should remove the settings references for the attributes that got removed. + // Also update the WidgetConfig attributeRefs field as usual + protected onAttributesSelect(ev: AttributesSelectEvent) { + const removedAttributeRefs = this.widgetConfig.attributeRefs.filter(ar => !ev.detail.attributeRefs.includes(ar)); + removedAttributeRefs.forEach(raf => { + this.removeFromAttributeSettings(raf); + this.removeFromColorPickedAttributes(raf); + }); + + this.widgetConfig.attributeRefs = ev.detail.attributeRefs; + this.notifyConfigUpdate(); + } + + protected removeFromAttributeSettings(attributeRef: AttributeRef) { + const settings = this.widgetConfig.attributeSettings; + (Object.keys(settings) as (keyof typeof settings)[]).forEach(key => { + settings[key] = settings[key].filter((ar: AttributeRef) => (ar.id !== attributeRef.id && ar.name !== attributeRef.name)); + }); + } + + protected toggleAttributeSetting( + setting: keyof ReportWidgetConfig["attributeSettings"], + attributeRef: AttributeRef, + ): void { + const attributes = this.widgetConfig.attributeSettings[setting]; + const index = attributes.findIndex( + (item: AttributeRef) => item.id === attributeRef.id && item.name === attributeRef.name + ); + if (index < 0) { + attributes.push(attributeRef); + } else { + attributes.splice(index, 1); + } + this.notifyConfigUpdate(); + } + + protected openColorPickDialog(attributeRef: AttributeRef) { + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.style.border = 'none'; + colorInput.style.height = '31px'; + colorInput.style.width = '31px'; + colorInput.style.padding = '1px 3px'; + colorInput.style.minHeight = '22px'; + colorInput.style.minWidth = '30px'; + colorInput.style.cursor = 'pointer'; + colorInput.addEventListener('change', (e: any) => { + const color = e.target.value; + const existingIndex = this.widgetConfig.colorPickedAttributes.findIndex(item => + item.attributeRef.id === attributeRef.id && item.attributeRef.name === attributeRef.name + ); + if (existingIndex >= 0) { + this.widgetConfig.colorPickedAttributes[existingIndex].color = color; + } else { + this.widgetConfig.colorPickedAttributes.push({ attributeRef, color }); + } + this.notifyConfigUpdate(); + }); + colorInput.click(); + } + + + + protected removeFromColorPickedAttributes(attributeRef: AttributeRef) { + this.widgetConfig.colorPickedAttributes = this.widgetConfig.colorPickedAttributes.filter( + item => item.attributeRef.id !== attributeRef.id || item.attributeRef.name !== attributeRef.name + ); + } + + + protected openAlgorithmMethodsDialog(attributeRef: AttributeRef) { + const methodList: ListItem[] = Object.entries(this.widgetConfig.attributeSettings) + .filter(([key]) => key.includes('method')) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, attributeRefs]) => { + const isActive = attributeRefs.some( + (ref: AttributeRef) => + ref.id === attributeRef.id && ref.name === attributeRef.name + ); + + return { + text: key, + value: key, + data: isActive ? key : undefined, + translate: true, + }; + }); + let selected: ListItem[] = []; + + showDialog(new OrMwcDialog() + .setContent(html` +
+ +
+ `) + .setHeading(i18next.t("algorithmMethod")) + .setActions([ + { + actionName: "cancel", + content: "cancel" + }, + { + default: true, + actionName: "ok", + action: () => { + // Check which settings need updating + const changedMethods = methodList.filter(input => { + const selectedItem = selected.find(selected => selected.value === input.value); + return (!selectedItem && input.data !== undefined) || + (selectedItem && selectedItem.data === undefined) || + (selectedItem && selectedItem.data !== input.data); + }); + //Update the settings + changedMethods.forEach((item: ListItem) => { + if (item.value) { + this.toggleAttributeSetting( + item.value as keyof ReportWidgetConfig["attributeSettings"], + attributeRef + ); + } + }); + }, + content: "ok" + } + ]) + .setDismissAction(null)); + } + + protected onTimePreFixSelect(ev: OrInputChangedEvent) { + this.widgetConfig.defaultTimePrefixKey = ev.detail.value.toString(); + this.notifyConfigUpdate(); + } + + protected onTimeWindowSelect(ev: OrInputChangedEvent) { + this.widgetConfig.defaultTimeWindowKey = ev.detail.value.toString(); + this.notifyConfigUpdate(); + } + + protected onTimestampControlsToggle(ev: OrInputChangedEvent) { + this.widgetConfig.showTimestampControls = !ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onShowLegendToggle(ev: OrInputChangedEvent) { + this.widgetConfig.chartSettings.showLegend = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onShowToolBoxToggle(ev: OrInputChangedEvent) { + this.widgetConfig.chartSettings.showToolBox = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onDefaultStackedToggle(ev: OrInputChangedEvent) { + this.widgetConfig.chartSettings.defaultStacked = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onIsChartToggle(ev: OrInputChangedEvent) { + this.widgetConfig.isChart = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected setAxisMinMaxValue(axis: 'left' | 'right', type: 'min' | 'max', value?: number) { + if(axis === 'left') { + if(type === 'min') { + this.widgetConfig.chartOptions.options.scales.y.min = value; + } else { + this.widgetConfig.chartOptions.options.scales.y.max = value; + } + } else { + if(type === 'min') { + this.widgetConfig.chartOptions.options.scales.y1.min = value; + } else { + this.widgetConfig.chartOptions.options.scales.y1.max = value; + } + } + this.notifyConfigUpdate(); + } + + protected onMinMaxValueChange(axis: 'left' | 'right', type: 'min' | 'max', ev: OrInputChangedEvent) { + this.setAxisMinMaxValue(axis, type, ev.detail.value); + } + + protected onMinMaxValueToggle(axis: 'left' | 'right', type: 'min' | 'max', ev: OrInputChangedEvent) { + this.setAxisMinMaxValue(axis, type, (ev.detail.value ? (type === 'min' ? 0 : 100) : undefined)); + } + + protected onDecimalsChange(ev: OrInputChangedEvent) { + this.widgetConfig.decimals = ev.detail.value; + this.notifyConfigUpdate(); + } + +} diff --git a/ui/component/or-dashboard-builder/src/widgets/report-widget.ts b/ui/component/or-dashboard-builder/src/widgets/report-widget.ts new file mode 100644 index 0000000000..5d285dd8c1 --- /dev/null +++ b/ui/component/or-dashboard-builder/src/widgets/report-widget.ts @@ -0,0 +1,257 @@ +import {AssetDatapointIntervalQuery, AssetDatapointQueryUnion, Attribute, AttributeRef} from "@openremote/model"; +import {html, PropertyValues, TemplateResult } from "lit"; +import { when } from "lit/directives/when.js"; +import moment from "moment"; +import {OrAssetWidget} from "../util/or-asset-widget"; +import { customElement, state } from "lit/decorators.js"; +import {WidgetConfig} from "../util/widget-config"; +import {OrWidget, WidgetManifest} from "../util/or-widget"; +import {ReportSettings} from "../settings/report-settings"; +import {WidgetSettings} from "../util/widget-settings"; +import "@openremote/or-attribute-report"; + +export interface ReportWidgetConfig extends WidgetConfig { + attributeRefs: AttributeRef[]; + colorPickedAttributes: Array<{ attributeRef: AttributeRef; color: string }>; + attributeSettings: { + rightAxisAttributes: AttributeRef[], + methodAvgAttributes: AttributeRef[], + methodMinAttributes: AttributeRef[], + methodMaxAttributes: AttributeRef[], + methodDeltaAttributes: AttributeRef[], + methodMedianAttributes: AttributeRef[], + methodModeAttributes: AttributeRef[], + methodSumAttributes: AttributeRef[], + methodCountAttributes: AttributeRef[], + }, + datapointQuery: AssetDatapointQueryUnion; + chartOptions?: any; + showTimestampControls: boolean; + defaultTimeWindowKey: string; + defaultTimePrefixKey: string; + chartSettings: { + showLegend: boolean; + showToolBox: boolean; + defaultStacked: boolean; + }; + isChart: boolean; + decimals: number; +} + +function getDefaultTimeWindowOptions(): Map { + return new Map([ + ["5Minutes", ['minutes', 5]], + ["20Minutes", ['minutes', 20]], + ["60Minutes", ['minutes', 60]], + ["hour", ['hours', 1]], + ["6Hours", ['hours', 6]], + ["24Hours", ['hours', 24]], + ["day", ['days', 1]], + ["7Days", ['days', 7]], + ["week", ['weeks', 1]], + ["30Days", ['days', 30]], + ["month", ['months', 1]], + ["365Days", ['days', 365]], + ["year", ['years', 1]] + ]); +} + +function getDefaultTimePreFixOptions(): string[] { + return ["this", "last"]; +} + +function getDefaultSamplingOptions(): Map { + return new Map([["lttb", 'lttb'], ["withInterval", 'interval']]); +} + +function getDefaultWidgetConfig(): ReportWidgetConfig { + const preset = "30Days"; // Default time preset, "last" prefix is hardcoded in startDate and endDate below. + const dateFunc = getDefaultTimeWindowOptions().get(preset); + const startDate = moment().subtract(dateFunc![1], dateFunc![0]).startOf(dateFunc![0]); + const endDate = dateFunc![1]== 1 ? moment().endOf(dateFunc![0]) : moment(); + return { + attributeRefs: [], + colorPickedAttributes: [], + attributeSettings: { + rightAxisAttributes: [], + methodAvgAttributes: [], + methodMinAttributes: [], + methodMaxAttributes: [], + methodDeltaAttributes: [], + methodMedianAttributes: [], + methodModeAttributes: [], + methodSumAttributes: [], + methodCountAttributes: [] + }, + datapointQuery: { + type: "interval", + fromTimestamp: startDate.toDate().getTime(), + toTimestamp: endDate.toDate().getTime(), + }, + chartOptions: { + options: { + scales: { + y: { + min: undefined, + max: undefined + }, + y1: { + min: undefined, + max: undefined + } + } + }, + }, + showTimestampControls: false, + defaultTimeWindowKey: preset, + defaultTimePrefixKey: "last", + chartSettings: { + showLegend: true, + showToolBox: false, + defaultStacked: false, + }, + isChart: true, + decimals: 2 + }; +} + +/* --------------------------------------------------- */ + +@customElement('report-widget') +export class ReportWidget extends OrAssetWidget { + + @state() + protected datapointQuery!: AssetDatapointQueryUnion; + + @state() + protected _loading = false; + + // Override of widgetConfig with extended type + protected widgetConfig!: ReportWidgetConfig; + + static getManifest(): WidgetManifest { + return { + displayName: "Report", + displayIcon: "chart-bar", + minColumnWidth: 2, + minColumnHeight: 2, + getContentHtml(config: ReportWidgetConfig): OrWidget { + return new ReportWidget(config); + }, + getSettingsHtml(config: ReportWidgetConfig): WidgetSettings { + const settings = new ReportSettings(config); + settings.setTimeWindowOptions(getDefaultTimeWindowOptions()); + settings.setTimePrefixOptions(getDefaultTimePreFixOptions()); + settings.setSamplingOptions(getDefaultSamplingOptions()); + return settings; + }, + getDefaultConfig(): ReportWidgetConfig { + return getDefaultWidgetConfig(); + } + } + } + + // Method called on every refresh/reload of the widget + // We either refresh the datapointQuery or the full widgetConfig depending on the force parameter. + // TODO: Improve this to a more efficient approach, instead of duplicating the object + public refreshContent(force: boolean) { + if(!force) { + const datapointQuery = JSON.parse(JSON.stringify(this.widgetConfig.datapointQuery)) as AssetDatapointQueryUnion; + datapointQuery.fromTimestamp = undefined; + datapointQuery.toTimestamp = undefined; + this.datapointQuery = datapointQuery; + } else { + this.widgetConfig = JSON.parse(JSON.stringify(this.widgetConfig)) as ReportWidgetConfig; + } + } + + + /* ---------------------------------- */ + + // WebComponent lifecycle method, that occurs DURING every state update + protected willUpdate(changedProps: PropertyValues) { + + // Add datapointQuery if not set yet (due to migration) + if(!this.widgetConfig.datapointQuery) { + this.widgetConfig.datapointQuery = this.getDefaultQuery(); + if(!changedProps.has("widgetConfig")) { + changedProps.set("widgetConfig", this.widgetConfig); + } + } + + if(changedProps.has('widgetConfig') && this.widgetConfig) { + this.datapointQuery = this.widgetConfig.datapointQuery; + + const attributeRefs = this.widgetConfig.attributeRefs; + if(attributeRefs.length === 0) { + this._error = "noAttributesConnected"; + } else { + const missingAssets = attributeRefs?.filter((attrRef: AttributeRef) => !this.isAttributeRefLoaded(attrRef)); + if (missingAssets.length > 0) { + this.loadAssets(attributeRefs); + } + } + } + + return super.willUpdate(changedProps); + } + + protected loadAssets(attributeRefs: AttributeRef[]): void { + if(attributeRefs.length === 0) { + this._error = "noAttributesConnected"; + return; + } + this._loading = true; + this._error = undefined; + this.fetchAssets(attributeRefs).then((assets) => { + this.loadedAssets = assets; + this.assetAttributes = attributeRefs?.map((attrRef: AttributeRef) => { + const assetIndex = assets.findIndex((asset) => asset.id === attrRef.id); + const foundAsset = assetIndex >= 0 ? assets[assetIndex] : undefined; + return foundAsset && foundAsset.attributes ? [assetIndex, foundAsset.attributes[attrRef.name!]] : undefined; + }).filter((indexAndAttr: any) => !!indexAndAttr) as [number, Attribute][]; + }).catch(e => { + this._error = e.message; + }).finally(() => { + this._loading = false; + }); + } + + protected render(): TemplateResult { + return html` + ${when(this._loading, () => html` + + + `, () => when(this._error, () => html` +
+ +
+ + `, () => { + return html` + + `; + }))} + `; + } + + protected getDefaultQuery(): AssetDatapointIntervalQuery { + return { + type: "interval", + fromTimestamp: moment().set('day', -30).toDate().getTime(), + toTimestamp: moment().toDate().getTime() + } + } +} diff --git a/yarn.lock b/yarn.lock index a48f8385c3..895245ffbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2553,6 +2553,30 @@ __metadata: languageName: unknown linkType: soft +"@openremote/or-attribute-report@workspace:*, @openremote/or-attribute-report@workspace:^, @openremote/or-attribute-report@workspace:ui/component/or-attribute-report": + version: 0.0.0-use.local + resolution: "@openremote/or-attribute-report@workspace:ui/component/or-attribute-report" + dependencies: + "@material/data-table": "npm:^9.0.0" + "@material/dialog": "npm:^9.0.0" + "@openremote/core": "workspace:*" + "@openremote/or-asset-tree": "workspace:*" + "@openremote/or-attribute-picker": "workspace:*" + "@openremote/or-components": "workspace:*" + "@openremote/or-icon": "workspace:*" + "@openremote/or-mwc-components": "workspace:*" + "@openremote/or-translate": "workspace:*" + "@openremote/util": "workspace:*" + "@types/chart.js": "npm:^2.9.34" + "@types/offscreencanvas": "npm:^2019.6.4" + chart.js: "npm:^3.6.0" + echarts: "npm:^5.6.0" + jsonpath-plus: "npm:^6.0.1" + lit: "npm:^2.0.2" + moment: "npm:^2.29.4" + languageName: unknown + linkType: soft + "@openremote/or-bottom-navigation@workspace:ui/component/or-bottom-navigation": version: 0.0.0-use.local resolution: "@openremote/or-bottom-navigation@workspace:ui/component/or-bottom-navigation" @@ -2605,6 +2629,7 @@ __metadata: dependencies: "@openremote/core": "workspace:*" "@openremote/model": "workspace:*" + "@openremote/or-attribute-report": "workspace:*" "@openremote/or-chart": "workspace:*" "@openremote/rest": "workspace:*" "@openremote/util": "workspace:*" @@ -4867,6 +4892,16 @@ __metadata: languageName: node linkType: hard +"echarts@npm:^5.6.0": + version: 5.6.0 + resolution: "echarts@npm:5.6.0" + dependencies: + tslib: "npm:2.3.0" + zrender: "npm:5.6.1" + checksum: 10c0/6d6a2ee88534d1ff0433e935c542237b9896de1c94959f47ebc7e0e9da26f59bf11c91ed6fc135b62ad2786c779ee12bc536fa481e60532dad5b6a2f5167e9ea + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -7792,6 +7827,7 @@ __metadata: version: 0.0.0-use.local resolution: "openremote@workspace:." dependencies: + "@openremote/or-attribute-report": "workspace:^" "@openremote/util": "workspace:*" languageName: unknown linkType: soft @@ -9401,6 +9437,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.3.0": + version: 2.3.0 + resolution: "tslib@npm:2.3.0" + checksum: 10c0/a845aed84e7e7dbb4c774582da60d7030ea39d67307250442d35c4c5dd77e4b44007098c37dd079e100029c76055f2a362734b8442ba828f8cc934f15ed9be61 + languageName: node + linkType: hard + "tslib@npm:^1.10.0, tslib@npm:^1.8.1, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -10071,3 +10114,12 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"zrender@npm:5.6.1": + version: 5.6.1 + resolution: "zrender@npm:5.6.1" + dependencies: + tslib: "npm:2.3.0" + checksum: 10c0/dc1cc570054640cbd8fbb7b92e6252f225319522bfe3e8dc8bf02cc02d414e00a4c8d0a6f89bfc9d96e5e9511fdca94dd3d06bf53690df2b2f12b0fc560ac307 + languageName: node + linkType: hard