From 6c624c0cd67d3c665d2d1868f83b87b9463f3134 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Fri, 14 Feb 2025 14:27:29 +0100 Subject: [PATCH 01/15] wip --- ui/component/or-chart/package.json | 1 + ui/component/or-chart/src/index - Copy.ts | 1216 +++++++++++++++++++++ 2 files changed, 1217 insertions(+) create mode 100644 ui/component/or-chart/src/index - Copy.ts diff --git a/ui/component/or-chart/package.json b/ui/component/or-chart/package.json index 97c2cb34a6..e8a65f17b4 100644 --- a/ui/component/or-chart/package.json +++ b/ui/component/or-chart/package.json @@ -28,6 +28,7 @@ "chart.js": "^3.6.0", "chartjs-adapter-moment": "^1.0.0", "chartjs-plugin-annotation": "^1.1.0", + "echarts": "^5.6.0", "jsonpath-plus": "^6.0.1", "lit": "^2.0.2", "moment": "^2.29.4" diff --git a/ui/component/or-chart/src/index - Copy.ts b/ui/component/or-chart/src/index - Copy.ts new file mode 100644 index 0000000000..a7c19bd5e6 --- /dev/null +++ b/ui/component/or-chart/src/index - Copy.ts @@ -0,0 +1,1216 @@ +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, + AssetDatapointQueryUnion, + AssetEvent, + AssetModelUtil, + AssetQuery, + Attribute, + AttributeRef, + DatapointInterval, + ReadAssetEvent, + ValueDatapoint, + WellknownMetaItems +} 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 { + Chart, + ChartConfiguration, + ChartDataset, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + ScatterController, + ScatterDataPoint, + TimeScale, + TimeScaleOptions, + TimeUnit, + Title, + Tooltip +} from "chart.js"; +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 ChartAnnotation, {AnnotationOptions} from "chartjs-plugin-annotation"; +import "chartjs-adapter-moment"; +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"; + +Chart.register(LineController, ScatterController, LineElement, PointElement, LinearScale, TimeScale, Title, Filler, Legend, Tooltip, ChartAnnotation); + +export class OrChartEvent extends CustomEvent { + + public static readonly NAME = "or-chart-event"; + + constructor(value?: any, previousValue?: any) { + super(OrChartEvent.NAME, { + detail: { + value: value, + previousValue: previousValue + }, + bubbles: true, + composed: true + }); + } +} + +export type TimePresetCallback = (date: Date) => [Date, Date]; + +export interface ChartViewConfig { + attributeRefs?: AttributeRef[]; + fromTimestamp?: number; + toTimestamp?: number; + /*compareOffset?: number;*/ + period?: moment.unitOfTime.Base; + deltaFormat?: "absolute" | "percentage"; + decimals?: number; +} + +export interface OrChartEventDetail { + value?: any; + previousValue?: any; +} + +declare global { + export interface HTMLElementEventMap { + [OrChartEvent.NAME]: OrChartEvent; + } +} + +export interface ChartConfig { + xLabel?: string; + yLabel?: string; +} + +export interface OrChartConfig { + chart?: ChartConfig; + realm?: string; + views: {[name: string]: { + [panelName: string]: ChartViewConfig + }}; +} + +// 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, 1); + --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: row; + 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: column; + margin: 0; + } + + #attribute-list { + overflow: hidden auto; + min-height: 50px; + flex: 1 1 0; + width: 100%; + display: flex; + flex-direction: column; + } + .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; + } + + .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; + /*min-height: 400px; + max-height: 550px;*/ + } + #chart-controls { + display: flex; + flex-direction: column; + align-items: center; + } + canvas { + width: 100% !important; + height: 100%; !important; + } + + @media screen and (max-width: 1280px) { + #chart-container { + max-height: 330px; + } + } + + @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-chart") +export class OrChart 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}) // List of AttributeRef that are shown on the right axis instead. + public rightAxisAttributes: AttributeRef[] = []; + + @property() + public dataProvider?: (startOfPeriod: number, endOfPeriod: number, timeUnits: TimeUnit, stepSize: number) => Promise[]> + + @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?: OrChartConfig; + + @property({type: Object}) // options that will get merged with our default chartjs configuration. + public chartOptions?: any + + @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() + public timePresetOptions?: Map; + + @property() + public timePresetKey?: string; + + @property() + public showLegend: boolean = true; + + @property() + public denseLegend: boolean = false; + + @property() + protected _loading: boolean = false; + + @property() + protected _data?: ChartDataset<"line", ScatterDataPoint[]>[] = undefined; + + @property() + protected _tableTemplate?: TemplateResult; + + @query("#chart") + protected _chartElem!: HTMLCanvasElement; + + protected _chart?: Chart; + protected _style!: CSSStyleDeclaration; + protected _startOfPeriod?: number; + protected _endOfPeriod?: number; + protected _timeUnits?: TimeUnit; + protected _stepSize?: number; + protected _latestError?: string; + protected _dataAbortController?: AbortController; + + 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("datapointQuery") || changedProperties.has("timePresetKey") || changedProperties.has("timeframe") || + changedProperties.has("rightAxisAttributes") || changedProperties.has("assetAttributes") || changedProperties.has("realm") || changedProperties.has("dataProvider"); + + if (reloadData) { + this._data = undefined; + if (this._chart) { + this._chart.destroy(); + this._chart = undefined; + } + this._loadData(); + } + + if (!this._data) { + return; + } + + const now = moment().toDate().getTime(); + + if (!this._chart) { + const options = { + type: "line", + data: { + datasets: this._data + }, + options: { + responsive: true, + maintainAspectRatio: false, + onResize: throttle(() => { this.dispatchEvent(new OrChartEvent("resize")); this.applyChartResponsiveness(); }, 200), + showLines: true, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: "x", + intersect: false, + xPadding: 10, + yPadding: 10, + titleMarginBottom: 10, + callbacks: { + label: (tooltipItem: any) => tooltipItem.dataset.label + ': ' + tooltipItem.formattedValue + tooltipItem.dataset.unit, + } + }, + annotation: { + annotations: [ + { + type: "line", + xMin: now, + xMax: now, + borderColor: "#275582", + borderWidth: 2 + } + ] + }, + }, + hover: { + mode: 'x', + intersect: false + }, + scales: { + y: { + ticks: { + beginAtZero: true + }, + grid: { + color: "#cccccc" + } + }, + y1: { + display: this.rightAxisAttributes.length > 0, + position: 'right', + ticks: { + beginAtZero: true + }, + grid: { + drawOnChartArea: false + } + }, + x: { + type: "time", + min: this._startOfPeriod, + max: this._endOfPeriod, + time: { + tooltipFormat: 'MMM D, YYYY, HH:mm:ss', + displayFormats: { + millisecond: 'HH:mm:ss.SSS', + second: 'HH:mm:ss', + minute: "HH:mm", + hour: (this._endOfPeriod && this._startOfPeriod && this._endOfPeriod - this._startOfPeriod > 86400000) ? "MMM DD, HH:mm" : "HH:mm", + day: "MMM DD", + week: "w" + }, + unit: this._timeUnits, + stepSize: this._stepSize + }, + ticks: { + autoSkip: true, + color: "#000", + font: { + family: "'Open Sans', Helvetica, Arial, Lucida, sans-serif", + size: 9, + style: "normal" + } + }, + gridLines: { + color: "#cccccc" + } + } + } + } + } as ChartConfiguration<"line", ScatterDataPoint[]>; + + const mergedOptions = Util.mergeObjects(options, this.chartOptions, false); + + this._chart = new Chart<"line", ScatterDataPoint[]>(this._chartElem.getContext("2d")!, mergedOptions as ChartConfiguration<"line", ScatterDataPoint[]>); + } else { + if (changedProperties.has("_data")) { + this._chart.options.scales!.x!.min = this._startOfPeriod; + this._chart.options!.scales!.x!.max = this._endOfPeriod; + (this._chart.options!.scales!.x! as TimeScaleOptions).time!.unit = this._timeUnits!; + (this._chart.options!.scales!.x! as TimeScaleOptions).time!.stepSize = this._stepSize!; + (this._chart.options!.plugins!.annotation!.annotations! as AnnotationOptions<"line">[])[0].xMin = now; + (this._chart.options!.plugins!.annotation!.annotations! as AnnotationOptions<"line">[])[0].xMax = now; + this._chart.data.datasets = this._data; + this._chart.update(); + } + } + this.onCompleted().then(() => { + this.dispatchEvent(new OrChartEvent('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 < 600); + 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._loading || this._latestError; + return html` +
+
+ ${when(this._loading, () => html` +
+ +
+ `)} + ${when(this._latestError, () => html` +
+ +
+ `)} + +
+ + ${(this.timestampControls || this.attributeControls || this.showLegend) ? html` +
+
+
+ ${this.timePresetOptions && this.timePresetKey ? html` + ${this.timestampControls ? html` + ${getContentWithMenuTemplate( + html``, + Array.from(this.timePresetOptions!.keys()).map((key) => ({ value: key } as ListItem)), + this.timePresetKey, + (value: string | string[]) => { + this.timeframe = undefined; // remove any custom start & end times + this.timePresetKey = value.toString(); + }, + undefined, + undefined, + undefined, + true + )} + + + ` : html` + + `} + ` : undefined} +
+ ${this.timeframe ? html` +
+ + + + + + + + + + + + + +
${i18next.t('from')}:${moment(this.timeframe[0]).format("L HH:mm")}
${i18next.t('to')}:${moment(this.timeframe[1]).format("L HH:mm")}
+
+ ` : undefined} + ${this.attributeControls ? html` + + ` : undefined} +
+ ${cache(this.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 descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset!.type, attr.name, attr); + const label = Util.getAttributeLabel(attr, descriptors[0], asset!.type, true); + const axisNote = (this.rightAxisAttributes.find(ar => asset!.id === ar.id && attr.name === ar.name)) ? i18next.t('right') : undefined; + const bgColor = this.colors[colourIndex] || ""; + return html` +
+ ${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(this.assets[assetIndex]!.type!), undefined, undefined, bgColor.split('#')[1])} +
+
+ ${this.assets[assetIndex].name} + ${when(axisNote, () => html`(${axisNote})`)} +
+ ${label} +
+
+ ` + })} +
+ ` : 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; + } + } + + removeDatasetHighlight(bgColor:string) { + if(this._chart && this._chart.data && this._chart.data.datasets){ + this._chart.data.datasets.map((dataset, idx) => { + if (dataset.borderColor && typeof dataset.borderColor === "string" && dataset.borderColor.length === 9) { + dataset.borderColor = dataset.borderColor.slice(0, -2); + dataset.backgroundColor = dataset.borderColor; + } + }); + this._chart.update(); + } + } + + addDatasetHighlight(assetId?:string, attrName?:string) { + if (!assetId || !attrName) return; + + if(this._chart && this._chart.data && this._chart.data.datasets){ + this._chart.data.datasets.map((dataset, idx) => { + if ((dataset as any).assetId === assetId && (dataset as any).attrName === attrName) { + return + } + dataset.borderColor = dataset.borderColor + "36"; + dataset.backgroundColor = dataset.borderColor; + }); + this._chart.update(); + } + } + + async loadSettings(reset: boolean) { + + if(this.assetAttributes == undefined || reset) { + this.assetAttributes = []; + } + + if (!this.realm) { + this.realm = manager.getRealm(); + } + + if (!this.timePresetOptions) { + this.timePresetOptions = this._getDefaultTimestampOptions(); + } + if (!this.timePresetKey) { + this.timePresetKey = this.timePresetOptions.keys().next().value.toString(); + } + + if (!this.panelName) { + return; + } + + const viewSelector = window.location.hash; + const allConfigs: OrChartConfig[] = await manager.console.retrieveData("OrChartConfig") || []; + + if (!Array.isArray(allConfigs)) { + manager.console.storeData("OrChartConfig", [allConfigs]); + } + + let config: OrChartConfig | 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: OrChartConfig[] = await manager.console.retrieveData("OrChartConfig") || []; + let config: OrChartConfig | 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) { + 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(this.timePresetOptions && 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) { + this._chart.destroy(); + 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 _getAttributeOptionsOld(): [string, string][] | undefined { + if(!this.activeAsset || !this.activeAsset.attributes) { + return; + } + + if(this.shadowRoot && this.shadowRoot.getElementById('chart-attribute-picker')) { + const elm = this.shadowRoot.getElementById('chart-attribute-picker') as HTMLInputElement; + elm.value = ''; + } + + const attributes = Object.values(this.activeAsset.attributes); + if (attributes && attributes.length > 0) { + return attributes + .filter((attribute) => attribute.meta && (attribute.meta.hasOwnProperty(WellknownMetaItems.STOREDATAPOINTS) ? attribute.meta[WellknownMetaItems.STOREDATAPOINTS] : attribute.meta.hasOwnProperty(WellknownMetaItems.AGENTLINK))) + .filter((attr) => (this.assetAttributes && !this.assetAttributes.some(([index, assetAttr]) => (assetAttr.name === attr.name) && this.assets[index].id === this.activeAsset!.id))) + .map((attr) => { + const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.activeAsset!.type, attr.name, attr); + const label = Util.getAttributeLabel(attr, descriptors[0], this.activeAsset!.type, false); + return [attr.name!, label]; + }); + } + } + + protected _getAttributeOptions(): [string, string][] | undefined { + if(!this.activeAsset || !this.activeAsset.attributes) { + return; + } + + if(this.shadowRoot && this.shadowRoot.getElementById('chart-attribute-picker')) { + const elm = this.shadowRoot.getElementById('chart-attribute-picker') as HTMLInputElement; + elm.value = ''; + } + + const attributes = Object.values(this.activeAsset.attributes); + if (attributes && attributes.length > 0) { + return attributes + .filter((attribute) => attribute.meta && (attribute.meta.hasOwnProperty(WellknownMetaItems.STOREDATAPOINTS) ? attribute.meta[WellknownMetaItems.STOREDATAPOINTS] : attribute.meta.hasOwnProperty(WellknownMetaItems.AGENTLINK))) + .filter((attr) => (this.assetAttributes && !this.assetAttributes.some(([index, assetAttr]) => (assetAttr.name === attr.name) && this.assets[index].id === this.activeAsset!.id))) + .map((attr) => { + const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.activeAsset!.type, attr.name, attr); + const label = Util.getAttributeLabel(attr, descriptors[0], this.activeAsset!.type, false); + return [attr.name!, label]; + }); + } + } + + protected _getDefaultTimestampOptions(): Map { + return new Map([ + ["lastHour", (date) => [moment(date).subtract(1, 'hour').toDate(), date]], + ["last24Hours", (date) => [moment(date).subtract(24, 'hours').toDate(), date]], + ["last7Days", (date) => [moment(date).subtract(7, 'days').toDate(), date]], + ["last30Days", (date) => [moment(date).subtract(30, 'days').toDate(), date]], + ["last90Days", (date) => [moment(date).subtract(90, 'days').toDate(), date]], + ["last6Months", (date) => [moment(date).subtract(6, 'months').toDate(), date]], + ["lastYear", (date) => [moment(date).subtract(1, 'year').toDate(), date]] + ]); + } + + protected _getInterval(diffInHours: number): [number, DatapointInterval] { + + if(diffInHours <= 1) { + return [5, DatapointInterval.MINUTE]; + } else if(diffInHours <= 3) { + return [10, DatapointInterval.MINUTE]; + } else if(diffInHours <= 6) { + return [30, DatapointInterval.MINUTE]; + } else if(diffInHours <= 24) { // one day + return [1, DatapointInterval.HOUR]; + } else if(diffInHours <= 48) { // two days + return [3, DatapointInterval.HOUR]; + } else if(diffInHours <= 96) { + return [12, DatapointInterval.HOUR]; + } else if(diffInHours <= 744) { // one month + return [1, DatapointInterval.DAY]; + } else { + return [1, DatapointInterval.MONTH]; + } + } + + protected async _loadData() { + if (this._data || !this.assetAttributes || !this.assets || (this.assets.length === 0 && !this.dataProvider) || (this.assetAttributes.length === 0 && !this.dataProvider) || !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.timePresetOptions!.get(this.timePresetKey!)!(new Date()); + this._startOfPeriod = this.timeframe ? this.timeframe[0].getTime() : dates[0].getTime(); + this._endOfPeriod = this.timeframe ? this.timeframe[1].getTime() : dates[1].getTime(); + + const diffInHours = (this._endOfPeriod - this._startOfPeriod) / 1000 / 60 / 60; + const intervalArr = this._getInterval(diffInHours); + + const stepSize: number = intervalArr[0]; + const interval: DatapointInterval = intervalArr[1]; + + const lowerCaseInterval = interval.toLowerCase(); + this._timeUnits = lowerCaseInterval as TimeUnit; + this._stepSize = stepSize; + const now = moment().toDate().getTime(); + let predictedFromTimestamp = now < this._startOfPeriod ? this._startOfPeriod : now; + + const data: ChartDataset<"line", ScatterDataPoint[]>[] = []; + let promises; + + try { + if(this.dataProvider) { + await this.dataProvider(this._startOfPeriod, this._endOfPeriod, (interval.toString() as TimeUnit), stepSize).then((dataset) => { + dataset.forEach((set) => { data.push(set); }); + }); + } else { + this._dataAbortController = new AbortController(); + promises = this.assetAttributes.map(async ([assetIndex, attribute], index) => { + + const asset = this.assets[assetIndex]; + const shownOnRightAxis = !!this.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + 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 }; + let dataset = await this._loadAttributeData(asset, attribute, this.colors[colourIndex], this._startOfPeriod!, this._endOfPeriod!, false, asset.name + " " + label, options); + (dataset as any).assetId = asset.id; + (dataset as any).attrName = attribute.name; + (dataset as any).unit = unit; + (dataset as any).yAxisID = shownOnRightAxis ? 'y1' : 'y'; + data.push(dataset); + + dataset = await this._loadAttributeData(this.assets[assetIndex], attribute, this.colors[colourIndex], predictedFromTimestamp, this._endOfPeriod!, true, asset.name + " " + label + " " + i18next.t("predicted"), options); + data.push(dataset); + }); + } + + if(promises) { + await Promise.all(promises); + } + + this._data = data; + 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 | undefined, from: number, to: number, predicted: boolean, label?: string, options?: any): Promise> { + + const dataset: ChartDataset<"line", ScatterDataPoint[]> = { + borderColor: color, + backgroundColor: color, + label: label, + pointRadius: 2, + fill: false, + data: [], + borderDash: predicted ? [2, 4] : undefined + }; + + 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; + + if(query.type == 'lttb') { + + // If amount of data points is set, only allow a maximum of 1 points per pixel in width + // Otherwise, dynamically set amount of data points based on chart width (1000px = 200 data points) + if(query.amountOfPoints) { + if(this._chartElem?.clientWidth > 0) { + query.amountOfPoints = Math.min(query.amountOfPoints, this._chartElem?.clientWidth) + } + } else { + if(this._chartElem?.clientWidth > 0) { + query.amountOfPoints = Math.round(this._chartElem.clientWidth / 5) + } else { + console.warn("Could not grab width of the Chart for estimating amount of data points. Using 100 points instead.") + query.amountOfPoints = 100; + } + } + + } else if(query.type === 'interval' && !query.interval) { + const diffInHours = (this.datapointQuery.toTimestamp! - this.datapointQuery.fromTimestamp!) / 1000 / 60 / 60; + const intervalArr = this._getInterval(diffInHours); + query.interval = (intervalArr[0].toString() + " " + intervalArr[1].toString()); // for example: "5 minute" + } + + if(!predicted) { + response = await manager.rest.api.AssetDatapointResource.getDatapoints(asset.id, attribute.name, query, options) + } else { + response = await manager.rest.api.AssetPredictedDatapointResource.getPredictedDatapoints(asset.id, attribute.name, query, options) + } + + if (response.status === 200) { + dataset.data = response.data.filter(value => value.y !== null && value.y !== undefined) as ScatterDataPoint[]; + } + } + + return dataset; + } + +} From 535ba6173b67cb84022e25363939ccb19b8fbff2 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Feb 2025 13:09:08 +0100 Subject: [PATCH 02/15] first succesful build with echarts --- .../or-attribute-history/src/index.ts | 167 ++- ui/component/or-chart/src/index - Copy.ts | 1216 ----------------- yarn.lock | 27 + 3 files changed, 180 insertions(+), 1230 deletions(-) delete mode 100644 ui/component/or-chart/src/index - Copy.ts diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 704529a482..d46f5042a2 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,6 +1,6 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; - +import * as echarts from "echarts"; // KIJKEN OF JE DE LIBRARY KUNT VERKLEINEN. import { css, html, @@ -254,11 +254,12 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { protected _tableTemplate?: TemplateResult; @query("#chart") - protected _chartElem!: HTMLCanvasElement; + protected _chartElem!: HTMLDivElement; + protected _chartOptions: echarts.EChartsOption = {}; @query("#table") protected _tableElem!: HTMLDivElement; protected _table?: MDCDataTable; - protected _chart?: Chart<"line", ScatterDataPoint[]>; + protected _chart?: echarts.ECharts; protected _type?: ValueDescriptor; protected _style!: CSSStyleDeclaration; protected _error?: string; @@ -368,7 +369,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { const isChart = this._type && (this._type.jsonType === "number" || this._type.jsonType === "boolean"); if (isChart) { - const data = this._data as ScatterDataPoint[]; + const data = this._data.map(point => [point.x, point.y]); if (!this._chart) { let bgColor = this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color").trim(); @@ -381,6 +382,126 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } } + this._chartOptions = { + //useUTC: true, + animation: false, + title: { + text: 'Dynamic Time Series Data', + left: 'center' + }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' } + }, + toolbox: { + right: '10%', //margin from right of frame in pixels + feature: { + saveAsImage: {}, + //restore: {}, + dataView: {}, + //dataZoom: {}, + magicType: { + type: ['line', 'bar', 'stack'] + }, + } + }, + legend: { + show: false // STAAT DEZE STANDAARD WEL AAN ??? + }, + xAxis: { + type: 'time', + //boundaryGap: false, + axisLine: { onZero: false }, + min: this._startOfPeriod, + max: this._endOfPeriod, // KLOPPEN DE TIJDEENHEDEN WEL? + axisLabel: { + //margin: 15, how much below the xAxis the labels are + showMinLabel: true, + showMaxLabel: true, + hideOverlap: true, + //rotate: '30', + //formatter: function (value, index) { + // var date = new Date(value); + // var time = date.getTime(); + // //console.log(time); + // // Show different formats for min and max labels + // if ((new Date().getTime() - time)<60000 || (time-(new Date().getTime()- 60 * 60 * 20000))<60000 ) { // 60000ms=60sec Deze ook nog aanpassen naar juiste input || date === (new Date().getTime()), also adjust when zooming to new zoom ends. + // return "{d}-{MMM}-'{yy} {HH}:{mm}"; // Dit aanpassen op basis van startTime en endTime. + // } else { + // return '{HH}:{mm}:{ss}'; //Dit straks aanpassen op basis van verschil tussen zoomStart en zoomEnd , als kleiner dan 10 min: seconde laten zien, als kleiner dan 1 dag, minuten laten zien. bij > 1 dag Feb-12 laten zien. + // } + //} + } + }, + yAxis: [ + { + type: 'value', + name: 'values', //<--------------------------------------- + boundaryGap: ['0%', '0%'], + scale: true + //axisLabel: { + // formatter: '{value} kW' //<--------------------------------------- + //} + } + ], + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100 + }, + { + start: 0, + end: 100 + } + ], + series: [ + { + name: 'values', //<--------------------------------------- + type: 'line', + showSymbol: false, + data: data, + sampling: 'lttb', + smooth: true, + //tooltip: { + // valueFormatter: value => value + ' kW' // adds units to tooltip + //}, + itemStyle: { + color: 'rgb(255, 70, 131)' + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: 'rgb(255, 158, 68)' + }, + { + offset: 1, + color: 'rgb(255, 70, 131)' + } + ]) + }, + markLine: { + symbol: 'circle', + data: [ + [ + { name: "Now", xAxis: '2025-02-19',yAxis: 'min' }, + { name: "end", xAxis: '2025-02-19',yAxis:'max' } + + ] + ], + lineStyle: { + color: "rgba(242, 145, 72, 1)", + type: 'solid', + width: 2 + } + } + } + ] + + } + + /* const options = { type: "line", data: { @@ -455,16 +576,33 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } } } as ChartConfiguration<"line", ScatterDataPoint[]>; + */ + + // DIT NOG OMZETTEN NAAR SHADOWROOT + + this._chart = echarts.init(this._chartElem); + //options.series[0].data = data; + this._chart.setOption(this._chartOptions); + + + //this._chart = new Chart<"line", ScatterDataPoint[]>(this._chartElem.getContext("2d")!, options); + + + //OPTIONS WORDEN NIET MEER DIRECT GEINJECTEERD, MOET JE HIER DUS ALSNOG HANDMATIG DOEN MET SHADOWROOT + //this._chart.series[0].data = data; - this._chart = new Chart<"line", ScatterDataPoint[]>(this._chartElem.getContext("2d")!, options); } else { if (changedProperties.has("_data")) { - this._chart.options.scales!.x!.min = this._startOfPeriod; - this._chart.options!.scales!.x!.max = this._endOfPeriod; - (this._chart.options!.scales!.x! as TimeScaleOptions).time!.unit = this._timeUnits!; - (this._chart.options!.scales!.x! as TimeScaleOptions).time!.stepSize = this._stepSize!; - this._chart.data.datasets![0].data = data; - this._chart.update(); + + this._chart.setOption({ + xAxis: { + min: this._startOfPeriod, + max: this._endOfPeriod + }, + series: [{ + data: data + }] + }); } } } else { @@ -488,7 +626,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._tableTemplate = undefined; if (this._chart) { - this._chart.destroy(); + this._chart.dispose(); this._chart = undefined; } if (this._table) { @@ -773,8 +911,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._loading = false; if (response.status === 200) { - this._data = response.data.filter(value => value.y !== null && value.y !== undefined) as ScatterDataPoint[]; - this._dataFirstLoaded = true; + this._data = response.data + .filter(value => value.y !== null && value.y !== undefined) + .map(point => ({ x: point.x, y: point.y } as ValueDatapoint)); } } catch (ex) { diff --git a/ui/component/or-chart/src/index - Copy.ts b/ui/component/or-chart/src/index - Copy.ts deleted file mode 100644 index a7c19bd5e6..0000000000 --- a/ui/component/or-chart/src/index - Copy.ts +++ /dev/null @@ -1,1216 +0,0 @@ -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, - AssetDatapointQueryUnion, - AssetEvent, - AssetModelUtil, - AssetQuery, - Attribute, - AttributeRef, - DatapointInterval, - ReadAssetEvent, - ValueDatapoint, - WellknownMetaItems -} 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 { - Chart, - ChartConfiguration, - ChartDataset, - Filler, - Legend, - LinearScale, - LineController, - LineElement, - PointElement, - ScatterController, - ScatterDataPoint, - TimeScale, - TimeScaleOptions, - TimeUnit, - Title, - Tooltip -} from "chart.js"; -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 ChartAnnotation, {AnnotationOptions} from "chartjs-plugin-annotation"; -import "chartjs-adapter-moment"; -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"; - -Chart.register(LineController, ScatterController, LineElement, PointElement, LinearScale, TimeScale, Title, Filler, Legend, Tooltip, ChartAnnotation); - -export class OrChartEvent extends CustomEvent { - - public static readonly NAME = "or-chart-event"; - - constructor(value?: any, previousValue?: any) { - super(OrChartEvent.NAME, { - detail: { - value: value, - previousValue: previousValue - }, - bubbles: true, - composed: true - }); - } -} - -export type TimePresetCallback = (date: Date) => [Date, Date]; - -export interface ChartViewConfig { - attributeRefs?: AttributeRef[]; - fromTimestamp?: number; - toTimestamp?: number; - /*compareOffset?: number;*/ - period?: moment.unitOfTime.Base; - deltaFormat?: "absolute" | "percentage"; - decimals?: number; -} - -export interface OrChartEventDetail { - value?: any; - previousValue?: any; -} - -declare global { - export interface HTMLElementEventMap { - [OrChartEvent.NAME]: OrChartEvent; - } -} - -export interface ChartConfig { - xLabel?: string; - yLabel?: string; -} - -export interface OrChartConfig { - chart?: ChartConfig; - realm?: string; - views: {[name: string]: { - [panelName: string]: ChartViewConfig - }}; -} - -// 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, 1); - --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: row; - 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: column; - margin: 0; - } - - #attribute-list { - overflow: hidden auto; - min-height: 50px; - flex: 1 1 0; - width: 100%; - display: flex; - flex-direction: column; - } - .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; - } - - .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; - /*min-height: 400px; - max-height: 550px;*/ - } - #chart-controls { - display: flex; - flex-direction: column; - align-items: center; - } - canvas { - width: 100% !important; - height: 100%; !important; - } - - @media screen and (max-width: 1280px) { - #chart-container { - max-height: 330px; - } - } - - @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-chart") -export class OrChart 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}) // List of AttributeRef that are shown on the right axis instead. - public rightAxisAttributes: AttributeRef[] = []; - - @property() - public dataProvider?: (startOfPeriod: number, endOfPeriod: number, timeUnits: TimeUnit, stepSize: number) => Promise[]> - - @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?: OrChartConfig; - - @property({type: Object}) // options that will get merged with our default chartjs configuration. - public chartOptions?: any - - @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() - public timePresetOptions?: Map; - - @property() - public timePresetKey?: string; - - @property() - public showLegend: boolean = true; - - @property() - public denseLegend: boolean = false; - - @property() - protected _loading: boolean = false; - - @property() - protected _data?: ChartDataset<"line", ScatterDataPoint[]>[] = undefined; - - @property() - protected _tableTemplate?: TemplateResult; - - @query("#chart") - protected _chartElem!: HTMLCanvasElement; - - protected _chart?: Chart; - protected _style!: CSSStyleDeclaration; - protected _startOfPeriod?: number; - protected _endOfPeriod?: number; - protected _timeUnits?: TimeUnit; - protected _stepSize?: number; - protected _latestError?: string; - protected _dataAbortController?: AbortController; - - 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("datapointQuery") || changedProperties.has("timePresetKey") || changedProperties.has("timeframe") || - changedProperties.has("rightAxisAttributes") || changedProperties.has("assetAttributes") || changedProperties.has("realm") || changedProperties.has("dataProvider"); - - if (reloadData) { - this._data = undefined; - if (this._chart) { - this._chart.destroy(); - this._chart = undefined; - } - this._loadData(); - } - - if (!this._data) { - return; - } - - const now = moment().toDate().getTime(); - - if (!this._chart) { - const options = { - type: "line", - data: { - datasets: this._data - }, - options: { - responsive: true, - maintainAspectRatio: false, - onResize: throttle(() => { this.dispatchEvent(new OrChartEvent("resize")); this.applyChartResponsiveness(); }, 200), - showLines: true, - plugins: { - legend: { - display: false - }, - tooltip: { - mode: "x", - intersect: false, - xPadding: 10, - yPadding: 10, - titleMarginBottom: 10, - callbacks: { - label: (tooltipItem: any) => tooltipItem.dataset.label + ': ' + tooltipItem.formattedValue + tooltipItem.dataset.unit, - } - }, - annotation: { - annotations: [ - { - type: "line", - xMin: now, - xMax: now, - borderColor: "#275582", - borderWidth: 2 - } - ] - }, - }, - hover: { - mode: 'x', - intersect: false - }, - scales: { - y: { - ticks: { - beginAtZero: true - }, - grid: { - color: "#cccccc" - } - }, - y1: { - display: this.rightAxisAttributes.length > 0, - position: 'right', - ticks: { - beginAtZero: true - }, - grid: { - drawOnChartArea: false - } - }, - x: { - type: "time", - min: this._startOfPeriod, - max: this._endOfPeriod, - time: { - tooltipFormat: 'MMM D, YYYY, HH:mm:ss', - displayFormats: { - millisecond: 'HH:mm:ss.SSS', - second: 'HH:mm:ss', - minute: "HH:mm", - hour: (this._endOfPeriod && this._startOfPeriod && this._endOfPeriod - this._startOfPeriod > 86400000) ? "MMM DD, HH:mm" : "HH:mm", - day: "MMM DD", - week: "w" - }, - unit: this._timeUnits, - stepSize: this._stepSize - }, - ticks: { - autoSkip: true, - color: "#000", - font: { - family: "'Open Sans', Helvetica, Arial, Lucida, sans-serif", - size: 9, - style: "normal" - } - }, - gridLines: { - color: "#cccccc" - } - } - } - } - } as ChartConfiguration<"line", ScatterDataPoint[]>; - - const mergedOptions = Util.mergeObjects(options, this.chartOptions, false); - - this._chart = new Chart<"line", ScatterDataPoint[]>(this._chartElem.getContext("2d")!, mergedOptions as ChartConfiguration<"line", ScatterDataPoint[]>); - } else { - if (changedProperties.has("_data")) { - this._chart.options.scales!.x!.min = this._startOfPeriod; - this._chart.options!.scales!.x!.max = this._endOfPeriod; - (this._chart.options!.scales!.x! as TimeScaleOptions).time!.unit = this._timeUnits!; - (this._chart.options!.scales!.x! as TimeScaleOptions).time!.stepSize = this._stepSize!; - (this._chart.options!.plugins!.annotation!.annotations! as AnnotationOptions<"line">[])[0].xMin = now; - (this._chart.options!.plugins!.annotation!.annotations! as AnnotationOptions<"line">[])[0].xMax = now; - this._chart.data.datasets = this._data; - this._chart.update(); - } - } - this.onCompleted().then(() => { - this.dispatchEvent(new OrChartEvent('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 < 600); - 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._loading || this._latestError; - return html` -
-
- ${when(this._loading, () => html` -
- -
- `)} - ${when(this._latestError, () => html` -
- -
- `)} - -
- - ${(this.timestampControls || this.attributeControls || this.showLegend) ? html` -
-
-
- ${this.timePresetOptions && this.timePresetKey ? html` - ${this.timestampControls ? html` - ${getContentWithMenuTemplate( - html``, - Array.from(this.timePresetOptions!.keys()).map((key) => ({ value: key } as ListItem)), - this.timePresetKey, - (value: string | string[]) => { - this.timeframe = undefined; // remove any custom start & end times - this.timePresetKey = value.toString(); - }, - undefined, - undefined, - undefined, - true - )} - - - ` : html` - - `} - ` : undefined} -
- ${this.timeframe ? html` -
- - - - - - - - - - - - - -
${i18next.t('from')}:${moment(this.timeframe[0]).format("L HH:mm")}
${i18next.t('to')}:${moment(this.timeframe[1]).format("L HH:mm")}
-
- ` : undefined} - ${this.attributeControls ? html` - - ` : undefined} -
- ${cache(this.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 descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset!.type, attr.name, attr); - const label = Util.getAttributeLabel(attr, descriptors[0], asset!.type, true); - const axisNote = (this.rightAxisAttributes.find(ar => asset!.id === ar.id && attr.name === ar.name)) ? i18next.t('right') : undefined; - const bgColor = this.colors[colourIndex] || ""; - return html` -
- ${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(this.assets[assetIndex]!.type!), undefined, undefined, bgColor.split('#')[1])} -
-
- ${this.assets[assetIndex].name} - ${when(axisNote, () => html`(${axisNote})`)} -
- ${label} -
-
- ` - })} -
- ` : 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; - } - } - - removeDatasetHighlight(bgColor:string) { - if(this._chart && this._chart.data && this._chart.data.datasets){ - this._chart.data.datasets.map((dataset, idx) => { - if (dataset.borderColor && typeof dataset.borderColor === "string" && dataset.borderColor.length === 9) { - dataset.borderColor = dataset.borderColor.slice(0, -2); - dataset.backgroundColor = dataset.borderColor; - } - }); - this._chart.update(); - } - } - - addDatasetHighlight(assetId?:string, attrName?:string) { - if (!assetId || !attrName) return; - - if(this._chart && this._chart.data && this._chart.data.datasets){ - this._chart.data.datasets.map((dataset, idx) => { - if ((dataset as any).assetId === assetId && (dataset as any).attrName === attrName) { - return - } - dataset.borderColor = dataset.borderColor + "36"; - dataset.backgroundColor = dataset.borderColor; - }); - this._chart.update(); - } - } - - async loadSettings(reset: boolean) { - - if(this.assetAttributes == undefined || reset) { - this.assetAttributes = []; - } - - if (!this.realm) { - this.realm = manager.getRealm(); - } - - if (!this.timePresetOptions) { - this.timePresetOptions = this._getDefaultTimestampOptions(); - } - if (!this.timePresetKey) { - this.timePresetKey = this.timePresetOptions.keys().next().value.toString(); - } - - if (!this.panelName) { - return; - } - - const viewSelector = window.location.hash; - const allConfigs: OrChartConfig[] = await manager.console.retrieveData("OrChartConfig") || []; - - if (!Array.isArray(allConfigs)) { - manager.console.storeData("OrChartConfig", [allConfigs]); - } - - let config: OrChartConfig | 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: OrChartConfig[] = await manager.console.retrieveData("OrChartConfig") || []; - let config: OrChartConfig | 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) { - 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(this.timePresetOptions && 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) { - this._chart.destroy(); - 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 _getAttributeOptionsOld(): [string, string][] | undefined { - if(!this.activeAsset || !this.activeAsset.attributes) { - return; - } - - if(this.shadowRoot && this.shadowRoot.getElementById('chart-attribute-picker')) { - const elm = this.shadowRoot.getElementById('chart-attribute-picker') as HTMLInputElement; - elm.value = ''; - } - - const attributes = Object.values(this.activeAsset.attributes); - if (attributes && attributes.length > 0) { - return attributes - .filter((attribute) => attribute.meta && (attribute.meta.hasOwnProperty(WellknownMetaItems.STOREDATAPOINTS) ? attribute.meta[WellknownMetaItems.STOREDATAPOINTS] : attribute.meta.hasOwnProperty(WellknownMetaItems.AGENTLINK))) - .filter((attr) => (this.assetAttributes && !this.assetAttributes.some(([index, assetAttr]) => (assetAttr.name === attr.name) && this.assets[index].id === this.activeAsset!.id))) - .map((attr) => { - const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.activeAsset!.type, attr.name, attr); - const label = Util.getAttributeLabel(attr, descriptors[0], this.activeAsset!.type, false); - return [attr.name!, label]; - }); - } - } - - protected _getAttributeOptions(): [string, string][] | undefined { - if(!this.activeAsset || !this.activeAsset.attributes) { - return; - } - - if(this.shadowRoot && this.shadowRoot.getElementById('chart-attribute-picker')) { - const elm = this.shadowRoot.getElementById('chart-attribute-picker') as HTMLInputElement; - elm.value = ''; - } - - const attributes = Object.values(this.activeAsset.attributes); - if (attributes && attributes.length > 0) { - return attributes - .filter((attribute) => attribute.meta && (attribute.meta.hasOwnProperty(WellknownMetaItems.STOREDATAPOINTS) ? attribute.meta[WellknownMetaItems.STOREDATAPOINTS] : attribute.meta.hasOwnProperty(WellknownMetaItems.AGENTLINK))) - .filter((attr) => (this.assetAttributes && !this.assetAttributes.some(([index, assetAttr]) => (assetAttr.name === attr.name) && this.assets[index].id === this.activeAsset!.id))) - .map((attr) => { - const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.activeAsset!.type, attr.name, attr); - const label = Util.getAttributeLabel(attr, descriptors[0], this.activeAsset!.type, false); - return [attr.name!, label]; - }); - } - } - - protected _getDefaultTimestampOptions(): Map { - return new Map([ - ["lastHour", (date) => [moment(date).subtract(1, 'hour').toDate(), date]], - ["last24Hours", (date) => [moment(date).subtract(24, 'hours').toDate(), date]], - ["last7Days", (date) => [moment(date).subtract(7, 'days').toDate(), date]], - ["last30Days", (date) => [moment(date).subtract(30, 'days').toDate(), date]], - ["last90Days", (date) => [moment(date).subtract(90, 'days').toDate(), date]], - ["last6Months", (date) => [moment(date).subtract(6, 'months').toDate(), date]], - ["lastYear", (date) => [moment(date).subtract(1, 'year').toDate(), date]] - ]); - } - - protected _getInterval(diffInHours: number): [number, DatapointInterval] { - - if(diffInHours <= 1) { - return [5, DatapointInterval.MINUTE]; - } else if(diffInHours <= 3) { - return [10, DatapointInterval.MINUTE]; - } else if(diffInHours <= 6) { - return [30, DatapointInterval.MINUTE]; - } else if(diffInHours <= 24) { // one day - return [1, DatapointInterval.HOUR]; - } else if(diffInHours <= 48) { // two days - return [3, DatapointInterval.HOUR]; - } else if(diffInHours <= 96) { - return [12, DatapointInterval.HOUR]; - } else if(diffInHours <= 744) { // one month - return [1, DatapointInterval.DAY]; - } else { - return [1, DatapointInterval.MONTH]; - } - } - - protected async _loadData() { - if (this._data || !this.assetAttributes || !this.assets || (this.assets.length === 0 && !this.dataProvider) || (this.assetAttributes.length === 0 && !this.dataProvider) || !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.timePresetOptions!.get(this.timePresetKey!)!(new Date()); - this._startOfPeriod = this.timeframe ? this.timeframe[0].getTime() : dates[0].getTime(); - this._endOfPeriod = this.timeframe ? this.timeframe[1].getTime() : dates[1].getTime(); - - const diffInHours = (this._endOfPeriod - this._startOfPeriod) / 1000 / 60 / 60; - const intervalArr = this._getInterval(diffInHours); - - const stepSize: number = intervalArr[0]; - const interval: DatapointInterval = intervalArr[1]; - - const lowerCaseInterval = interval.toLowerCase(); - this._timeUnits = lowerCaseInterval as TimeUnit; - this._stepSize = stepSize; - const now = moment().toDate().getTime(); - let predictedFromTimestamp = now < this._startOfPeriod ? this._startOfPeriod : now; - - const data: ChartDataset<"line", ScatterDataPoint[]>[] = []; - let promises; - - try { - if(this.dataProvider) { - await this.dataProvider(this._startOfPeriod, this._endOfPeriod, (interval.toString() as TimeUnit), stepSize).then((dataset) => { - dataset.forEach((set) => { data.push(set); }); - }); - } else { - this._dataAbortController = new AbortController(); - promises = this.assetAttributes.map(async ([assetIndex, attribute], index) => { - - const asset = this.assets[assetIndex]; - const shownOnRightAxis = !!this.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); - 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 }; - let dataset = await this._loadAttributeData(asset, attribute, this.colors[colourIndex], this._startOfPeriod!, this._endOfPeriod!, false, asset.name + " " + label, options); - (dataset as any).assetId = asset.id; - (dataset as any).attrName = attribute.name; - (dataset as any).unit = unit; - (dataset as any).yAxisID = shownOnRightAxis ? 'y1' : 'y'; - data.push(dataset); - - dataset = await this._loadAttributeData(this.assets[assetIndex], attribute, this.colors[colourIndex], predictedFromTimestamp, this._endOfPeriod!, true, asset.name + " " + label + " " + i18next.t("predicted"), options); - data.push(dataset); - }); - } - - if(promises) { - await Promise.all(promises); - } - - this._data = data; - 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 | undefined, from: number, to: number, predicted: boolean, label?: string, options?: any): Promise> { - - const dataset: ChartDataset<"line", ScatterDataPoint[]> = { - borderColor: color, - backgroundColor: color, - label: label, - pointRadius: 2, - fill: false, - data: [], - borderDash: predicted ? [2, 4] : undefined - }; - - 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; - - if(query.type == 'lttb') { - - // If amount of data points is set, only allow a maximum of 1 points per pixel in width - // Otherwise, dynamically set amount of data points based on chart width (1000px = 200 data points) - if(query.amountOfPoints) { - if(this._chartElem?.clientWidth > 0) { - query.amountOfPoints = Math.min(query.amountOfPoints, this._chartElem?.clientWidth) - } - } else { - if(this._chartElem?.clientWidth > 0) { - query.amountOfPoints = Math.round(this._chartElem.clientWidth / 5) - } else { - console.warn("Could not grab width of the Chart for estimating amount of data points. Using 100 points instead.") - query.amountOfPoints = 100; - } - } - - } else if(query.type === 'interval' && !query.interval) { - const diffInHours = (this.datapointQuery.toTimestamp! - this.datapointQuery.fromTimestamp!) / 1000 / 60 / 60; - const intervalArr = this._getInterval(diffInHours); - query.interval = (intervalArr[0].toString() + " " + intervalArr[1].toString()); // for example: "5 minute" - } - - if(!predicted) { - response = await manager.rest.api.AssetDatapointResource.getDatapoints(asset.id, attribute.name, query, options) - } else { - response = await manager.rest.api.AssetPredictedDatapointResource.getPredictedDatapoints(asset.id, attribute.name, query, options) - } - - if (response.status === 200) { - dataset.data = response.data.filter(value => value.y !== null && value.y !== undefined) as ScatterDataPoint[]; - } - } - - return dataset; - } - -} diff --git a/yarn.lock b/yarn.lock index 81952301f4..c411eaba7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2581,6 +2581,7 @@ __metadata: chart.js: "npm:^3.6.0" chartjs-adapter-moment: "npm:^1.0.0" chartjs-plugin-annotation: "npm:^1.1.0" + echarts: "npm:^5.6.0" jsonpath-plus: "npm:^6.0.1" lit: "npm:^2.0.2" moment: "npm:^2.29.4" @@ -4853,6 +4854,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" @@ -9356,6 +9367,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" @@ -10026,3 +10044,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 From d5d538c1dc4d94264ead9c535b3d5a10b1ddce11 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Feb 2025 14:12:42 +0100 Subject: [PATCH 03/15] small tweaks --- .../or-attribute-history/src/index.ts | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index d46f5042a2..76dc7a4a46 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -184,6 +184,11 @@ const style = css` padding-left: 5px; } + #chart { + width: 100%; + height: 100%; + } + #chart-container { position: relative; min-height: 250px; @@ -342,7 +347,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { `)}
- +
`, () => html` @@ -383,12 +388,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } this._chartOptions = { - //useUTC: true, animation: false, - title: { - text: 'Dynamic Time Series Data', - left: 'center' - }, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } @@ -396,13 +397,12 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { toolbox: { right: '10%', //margin from right of frame in pixels feature: { - saveAsImage: {}, - //restore: {}, dataView: {}, - //dataZoom: {}, magicType: { - type: ['line', 'bar', 'stack'] + type: ['line', 'bar'] }, + restore: {}, + saveAsImage: {} } }, legend: { @@ -410,10 +410,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { }, xAxis: { type: 'time', - //boundaryGap: false, axisLine: { onZero: false }, min: this._startOfPeriod, - max: this._endOfPeriod, // KLOPPEN DE TIJDEENHEDEN WEL? + max: this._endOfPeriod, axisLabel: { //margin: 15, how much below the xAxis the labels are showMinLabel: true, @@ -462,7 +461,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { showSymbol: false, data: data, sampling: 'lttb', - smooth: true, //tooltip: { // valueFormatter: value => value + ' kW' // adds units to tooltip //}, @@ -485,8 +483,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { symbol: 'circle', data: [ [ - { name: "Now", xAxis: '2025-02-19',yAxis: 'min' }, - { name: "end", xAxis: '2025-02-19',yAxis:'max' } + { name: "Now", xAxis: new Date().toISOString() ,yAxis: 'min' }, + { name: "end", xAxis: new Date().toISOString() ,yAxis:'max' } ] ], @@ -578,19 +576,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } as ChartConfiguration<"line", ScatterDataPoint[]>; */ - // DIT NOG OMZETTEN NAAR SHADOWROOT - this._chart = echarts.init(this._chartElem); - //options.series[0].data = data; this._chart.setOption(this._chartOptions); - - //this._chart = new Chart<"line", ScatterDataPoint[]>(this._chartElem.getContext("2d")!, options); - - - //OPTIONS WORDEN NIET MEER DIRECT GEINJECTEERD, MOET JE HIER DUS ALSNOG HANDMATIG DOEN MET SHADOWROOT - //this._chart.series[0].data = data; - } else { if (changedProperties.has("_data")) { From d4e16aabb4d5dc2b9283388422083f0786a96eca Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Feb 2025 15:55:39 +0100 Subject: [PATCH 04/15] working with labels in tooltip & y-axis --- .../or-attribute-history/src/index.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 76dc7a4a46..7aa1e7678b 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -13,7 +13,7 @@ import {customElement, property, query} from "lit/decorators.js"; import i18next from "i18next"; import {translate} from "@openremote/or-translate"; import {AssetModelUtil, Attribute, AttributeRef, DatapointInterval, ValueDatapoint, ValueDescriptor} from "@openremote/model"; -import manager, {DefaultColor2, DefaultColor3, DefaultColor4, DefaultColor5} from "@openremote/core"; +import manager, {DefaultColor2, DefaultColor3, DefaultColor4, DefaultColor5, Util} from "@openremote/core"; import "@openremote/or-mwc-components/or-mwc-input"; import "@openremote/or-components/or-panel"; import "@openremote/or-translate"; @@ -375,6 +375,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { if (isChart) { const data = this._data.map(point => [point.x, point.y]); + const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.assetType!, this.attribute!.name!, this.attribute!); + const label = Util.getAttributeLabel(this.attribute!, descriptors[0], this.assetType!, true); if (!this._chart) { let bgColor = this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color").trim(); @@ -389,10 +391,13 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._chartOptions = { animation: false, - tooltip: { trigger: 'axis', - axisPointer: { type: 'cross' } + axisPointer: { type: 'cross' }, + //formatter: (params: any[]) => { + // // Custom formatter to hide the series name in the tooltip + // return params.map(item => `${item.value}`).join('
'); + //} }, toolbox: { right: '10%', //margin from right of frame in pixels @@ -405,9 +410,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { saveAsImage: {} } }, - legend: { - show: false // STAAT DEZE STANDAARD WEL AAN ??? - }, + //legend: { + // show: false // STAAT DEZE STANDAARD WEL AAN ??? + //}, xAxis: { type: 'time', axisLine: { onZero: false }, @@ -435,11 +440,14 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { yAxis: [ { type: 'value', - name: 'values', //<--------------------------------------- + name: label, boundaryGap: ['0%', '0%'], - scale: true + scale: true, //axisLabel: { - // formatter: '{value} kW' //<--------------------------------------- + // formatter: (value: number) => { + // // Custom formatter to hide the series name in the yAxis label + // return value.toString(); // Ensure the formatter returns a string + // } //} } ], @@ -456,7 +464,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { ], series: [ { - name: 'values', //<--------------------------------------- + name: label, type: 'line', showSymbol: false, data: data, From d8ba3d80263ee6ace176b1c409f60a316553c1a3 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Feb 2025 17:58:55 +0100 Subject: [PATCH 05/15] working with label in tooltip but markline broken --- .../or-attribute-history/src/index.ts | 145 ++++-------------- 1 file changed, 27 insertions(+), 118 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 7aa1e7678b..c1c608e67c 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,6 +1,6 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; -import * as echarts from "echarts"; // KIJKEN OF JE DE LIBRARY KUNT VERKLEINEN. +import * as echarts from "echarts"; // REDUCE LIBRARY IMPORTS TO ONLY USED CLASSES. import { css, html, @@ -191,7 +191,7 @@ const style = css` #chart-container { position: relative; - min-height: 250px; + min-height: 300px; } #table-container { @@ -375,8 +375,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { if (isChart) { const data = this._data.map(point => [point.x, point.y]); - const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.assetType!, this.attribute!.name!, this.attribute!); - const label = Util.getAttributeLabel(this.attribute!, descriptors[0], this.assetType!, true); + //const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.assetType!, this.attribute!.name!, this.attribute!); + //const label = Util.getAttributeLabel(this.attribute!, descriptors[0], this.assetType!, true); if (!this._chart) { let bgColor = this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color").trim(); @@ -389,18 +389,25 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } } + type CustomTooltipFormatter = (params: any) => string; + + //const tooltipFormatter: CustomTooltipFormatter = (params) => { + // if (Array.isArray(params)) { + // return params.map((item: any) => `${item.value}`).join('
'); + // } + // return `${params.value}`; + //}; + + this._chartOptions = { animation: false, tooltip: { trigger: 'axis', - axisPointer: { type: 'cross' }, - //formatter: (params: any[]) => { - // // Custom formatter to hide the series name in the tooltip - // return params.map(item => `${item.value}`).join('
'); - //} + axisPointer: { type: 'cross'}, + //formatter: tooltipFormatter, }, toolbox: { - right: '10%', //margin from right of frame in pixels + right: '10%', feature: { dataView: {}, magicType: { @@ -410,45 +417,22 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { saveAsImage: {} } }, - //legend: { - // show: false // STAAT DEZE STANDAARD WEL AAN ??? - //}, xAxis: { type: 'time', axisLine: { onZero: false }, min: this._startOfPeriod, max: this._endOfPeriod, axisLabel: { - //margin: 15, how much below the xAxis the labels are showMinLabel: true, showMaxLabel: true, hideOverlap: true, - //rotate: '30', - //formatter: function (value, index) { - // var date = new Date(value); - // var time = date.getTime(); - // //console.log(time); - // // Show different formats for min and max labels - // if ((new Date().getTime() - time)<60000 || (time-(new Date().getTime()- 60 * 60 * 20000))<60000 ) { // 60000ms=60sec Deze ook nog aanpassen naar juiste input || date === (new Date().getTime()), also adjust when zooming to new zoom ends. - // return "{d}-{MMM}-'{yy} {HH}:{mm}"; // Dit aanpassen op basis van startTime en endTime. - // } else { - // return '{HH}:{mm}:{ss}'; //Dit straks aanpassen op basis van verschil tussen zoomStart en zoomEnd , als kleiner dan 10 min: seconde laten zien, als kleiner dan 1 dag, minuten laten zien. bij > 1 dag Feb-12 laten zien. - // } - //} } }, yAxis: [ { type: 'value', - name: label, - boundaryGap: ['0%', '0%'], - scale: true, - //axisLabel: { - // formatter: (value: number) => { - // // Custom formatter to hide the series name in the yAxis label - // return value.toString(); // Ensure the formatter returns a string - // } - //} + boundaryGap: ['10%', '10%'], + scale: true } ], dataZoom: [ @@ -464,13 +448,14 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { ], series: [ { - name: label, + name: 'label', type: 'line', showSymbol: false, data: data, sampling: 'lttb', //tooltip: { - // valueFormatter: value => value + ' kW' // adds units to tooltip + // //valueFormatter: value => value + ' kW' // adds units to tooltip + // formatter: '{c}' //}, itemStyle: { color: 'rgb(255, 70, 131)' @@ -489,6 +474,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { }, markLine: { symbol: 'circle', + silent: true, data: [ [ { name: "Now", xAxis: new Date().toISOString() ,yAxis: 'min' }, @@ -497,99 +483,22 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { ] ], lineStyle: { - color: "rgba(242, 145, 72, 1)", + color: "rgba(54,96,138,255)", type: 'solid', width: 2 } } } ] - } - - /* - const options = { - type: "line", - data: { - datasets: [ - { - data: data, - backgroundColor: bgColor, - borderColor: this._style.getPropertyValue("--internal-or-attribute-history-graph-line-color"), - pointBorderColor: this._style.getPropertyValue("--internal-or-attribute-history-graph-point-border-color"), - pointBackgroundColor: this._style.getPropertyValue("--internal-or-attribute-history-graph-point-color"), - pointRadius: Number(this._style.getPropertyValue("--internal-or-attribute-history-graph-point-radius")), - pointBorderWidth: Number(this._style.getPropertyValue("--internal-or-attribute-history-graph-point-border-width")), - pointHoverBackgroundColor: this._style.getPropertyValue("--internal-or-attribute-history-graph-point-hover-color"), - pointHoverBorderColor: this._style.getPropertyValue("--internal-or-attribute-history-graph-point-hover-border-color"), - pointHoverRadius: Number(this._style.getPropertyValue("--internal-or-attribute-history-graph-point-hover-radius")), - pointHoverBorderWidth: Number(this._style.getPropertyValue("--internal-or-attribute-history-graph-point-hover-border-width")), - pointHitRadius: Number(this._style.getPropertyValue("--internal-or-attribute-history-graph-point-hit-radius")), - fill: false - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - onResize:() => this.dispatchEvent(new OrAttributeHistoryEvent('resize')), - plugins: { - legend: { - display: false - }, - tooltip: { - displayColors: false, - xPadding: 10, - yPadding: 10, - titleMarginBottom: 10 - } - }, - scales: { - y: { - beginAtZero: true, - grid: { - color: "#cccccc" - } - }, - x: { - type: "time", - min: this._startOfPeriod, - max: this._endOfPeriod, - time: { - tooltipFormat: 'MMM D, YYYY, HH:mm:ss', - displayFormats: { - millisecond: 'HH:mm:ss.SSS', - second: 'HH:mm:ss', - minute: "HH:mm", - hour: "HH:mm", - week: "w" - }, - unit: this._timeUnits, - stepSize: this._stepSize - }, - ticks: { - color: "#000", - font: { - family: "'Open Sans', Helvetica, Arial, Lucida, sans-serif", - size: 9, - style: "normal" - } - }, - gridLines: { - color: "#cccccc" - } - } - } - } - } as ChartConfiguration<"line", ScatterDataPoint[]>; - */ - + // Initialize chart this._chart = echarts.init(this._chartElem); + // Set chart options to default this._chart.setOption(this._chartOptions); } else { if (changedProperties.has("_data")) { - + //Update chart data this._chart.setOption({ xAxis: { min: this._startOfPeriod, From 9b0e83bc40a3deaea8a297598eba9a5dfc7eb635 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Thu, 20 Feb 2025 15:23:50 +0100 Subject: [PATCH 06/15] fully replicated, needs library cleaning --- .../or-attribute-history/src/index.ts | 101 ++++++++++-------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index c1c608e67c..4936deced9 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,6 +1,6 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; -import * as echarts from "echarts"; // REDUCE LIBRARY IMPORTS TO ONLY USED CLASSES. +import * as echarts from "echarts"; // REDUCE LIBRARY IMPORTS TO ONLY USED CLASSES. marklinecomponent , EChartsOption, ECharts, EChartsResponsiveOption, EChartsSeriesType, EChartsTitleOption, EChartsToolboxFeature, EChartsXAxisOption, EChartsYAxisOption import { css, html, @@ -109,7 +109,7 @@ const style = css` --internal-or-attribute-history-controls-margin: var(--or-attribute-history-controls-margin, 10px 0); --internal-or-attribute-history-controls-justify-content: var(--or-attribute-history-controls-justify-content, flex-end); --internal-or-attribute-history-graph-fill-color: var(--or-attribute-history-graph-fill-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); - --internal-or-attribute-history-graph-fill-opacity: var(--or-attribute-history-graph-fill-opacity, 1); + --internal-or-attribute-history-graph-fill-opacity: var(--or-attribute-history-graph-fill-opacity, 0.25); --internal-or-attribute-history-graph-line-color: var(--or-attribute-history-graph-line-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); --internal-or-attribute-history-graph-point-color: var(--or-attribute-history-graph-point-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); --internal-or-attribute-history-graph-point-border-color: var(--or-attribute-history-graph-point-border-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); @@ -191,7 +191,7 @@ const style = css` #chart-container { position: relative; - min-height: 300px; + min-height: 350px; } #table-container { @@ -374,9 +374,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { const isChart = this._type && (this._type.jsonType === "number" || this._type.jsonType === "boolean"); if (isChart) { + const data = this._data.map(point => [point.x, point.y]); - //const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(this.assetType!, this.attribute!.name!, this.attribute!); - //const label = Util.getAttributeLabel(this.attribute!, descriptors[0], this.assetType!, true); if (!this._chart) { let bgColor = this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color").trim(); @@ -389,25 +388,28 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } } - type CustomTooltipFormatter = (params: any) => string; - - //const tooltipFormatter: CustomTooltipFormatter = (params) => { - // if (Array.isArray(params)) { - // return params.map((item: any) => `${item.value}`).join('
'); - // } - // return `${params.value}`; - //}; - - this._chartOptions = { animation: false, + grid: { + show: true, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-viewer-panel-color"), + borderColor: this._style.getPropertyValue("--internal-or-attribute-history-text-color") + }, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-viewer-panel-color"), tooltip: { trigger: 'axis', axisPointer: { type: 'cross'}, - //formatter: tooltipFormatter, + formatter: (params: any) => { + if (Array.isArray(params) && params.length > 0) { + const yValue = params[0].value[1]; + return yValue !== undefined ? yValue.toString() : ''; + } + return '' + } }, toolbox: { - right: '10%', + right: '9%', + top: '5%', feature: { dataView: {}, magicType: { @@ -419,7 +421,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { }, xAxis: { type: 'time', - axisLine: { onZero: false }, + axisLine: { onZero: false, lineStyle: {color: this._style.getPropertyValue("--internal-or-attribute-history-text-color")}}, + splitLine: {show:true}, min: this._startOfPeriod, max: this._endOfPeriod, axisLabel: { @@ -428,13 +431,13 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { hideOverlap: true, } }, - yAxis: [ + yAxis: { type: 'value', + axisLine: { lineStyle: {color: this._style.getPropertyValue("--internal-or-attribute-history-text-color")}}, boundaryGap: ['10%', '10%'], scale: true - } - ], + }, dataZoom: [ { type: 'inside', @@ -443,7 +446,33 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { }, { start: 0, - end: 100 + end: 100, + backgroundColor: bgColor, + fillerColor: bgColor, + dataBackground: { + areaStyle: { + color: this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color") + } + }, + selectedDataBackground: { + areaStyle: { + color: this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color"), + } + }, + moveHandleStyle: { + color: this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color") + }, + emphasis: { + moveHandleStyle: { + color: this._style.getPropertyValue("--internal-or-attribute-history-graph-fill-color") + }, + handleLabel: { + show: true + } + }, + handleLabel: { + show: false + } } ], series: [ @@ -453,37 +482,15 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { showSymbol: false, data: data, sampling: 'lttb', - //tooltip: { - // //valueFormatter: value => value + ' kW' // adds units to tooltip - // formatter: '{c}' - //}, itemStyle: { - color: 'rgb(255, 70, 131)' - }, - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { - offset: 0, - color: 'rgb(255, 158, 68)' - }, - { - offset: 1, - color: 'rgb(255, 70, 131)' - } - ]) + color: this._style.getPropertyValue("--internal-or-attribute-history-graph-line-color") }, markLine: { symbol: 'circle', silent: true, - data: [ - [ - { name: "Now", xAxis: new Date().toISOString() ,yAxis: 'min' }, - { name: "end", xAxis: new Date().toISOString() ,yAxis:'max' } - - ] - ], + data: [ { name: 'now', xAxis: new Date().toISOString(), label: {formatter: '{b}'}} ], lineStyle: { - color: "rgba(54,96,138,255)", + color: this._style.getPropertyValue("--internal-or-attribute-history-text-color"), type: 'solid', width: 2 } From 90757fdde92278318f12b95b0482891ced8abcaa Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Thu, 20 Feb 2025 15:46:28 +0100 Subject: [PATCH 07/15] library cleaned. some legacy functions might require cleaning --- ui/component/or-attribute-history/src/index.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 4936deced9..7f6c8cfa01 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,6 +1,6 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; -import * as echarts from "echarts"; // REDUCE LIBRARY IMPORTS TO ONLY USED CLASSES. marklinecomponent , EChartsOption, ECharts, EChartsResponsiveOption, EChartsSeriesType, EChartsTitleOption, EChartsToolboxFeature, EChartsXAxisOption, EChartsYAxisOption +import {ECharts, EChartsOption, init} from "echarts"; import { css, html, @@ -13,12 +13,11 @@ import {customElement, property, query} from "lit/decorators.js"; import i18next from "i18next"; import {translate} from "@openremote/or-translate"; import {AssetModelUtil, Attribute, AttributeRef, DatapointInterval, ValueDatapoint, ValueDescriptor} from "@openremote/model"; -import manager, {DefaultColor2, DefaultColor3, DefaultColor4, DefaultColor5, Util} from "@openremote/core"; +import manager, {DefaultColor2, DefaultColor3, DefaultColor4, DefaultColor5} from "@openremote/core"; import "@openremote/or-mwc-components/or-mwc-input"; import "@openremote/or-components/or-panel"; import "@openremote/or-translate"; import "@openremote/or-chart"; -import {Chart, ScatterDataPoint, ChartConfiguration, TimeUnit, TimeScaleOptions} from "chart.js"; import "chartjs-adapter-moment"; import {InputType, OrInputChangedEvent} from "@openremote/or-mwc-components/or-mwc-input"; import {MDCDataTable} from "@material/data-table"; @@ -260,17 +259,17 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { @query("#chart") protected _chartElem!: HTMLDivElement; - protected _chartOptions: echarts.EChartsOption = {}; + protected _chartOptions: EChartsOption = {}; @query("#table") protected _tableElem!: HTMLDivElement; protected _table?: MDCDataTable; - protected _chart?: echarts.ECharts; + protected _chart?: ECharts; protected _type?: ValueDescriptor; protected _style!: CSSStyleDeclaration; protected _error?: string; protected _startOfPeriod?: number; protected _endOfPeriod?: number; - protected _timeUnits?: TimeUnit; + //protected _timeUnits?: TimeUnit; Chart.js legacy protected _stepSize?: number; protected _updateTimestampTimer?: number; protected _dataAbortController?: AbortController; @@ -498,8 +497,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } ] } - // Initialize chart - this._chart = echarts.init(this._chartElem); + // Initialize echarts instance + this._chart = init(this._chartElem); // Set chart options to default this._chart.setOption(this._chartOptions); @@ -790,7 +789,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { const lowerCaseInterval = interval.toLowerCase(); this._startOfPeriod = moment(this.toTimestamp).subtract(1, this.period).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); this._endOfPeriod = moment(this.toTimestamp).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); - this._timeUnits = lowerCaseInterval as TimeUnit; + //this._timeUnits = lowerCaseInterval as TimeUnit; Chart.js legacy this._stepSize = stepSize; const isChart = this._type && (this._type.jsonType === "number" || this._type.jsonType === "boolean"); From 6c2863eb7773955877f0a027a1f395c852e0d1f8 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Thu, 20 Feb 2025 18:14:32 +0100 Subject: [PATCH 08/15] add resize eventlisteners --- ui/component/or-attribute-history/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 7f6c8cfa01..e2a27664b3 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -410,7 +410,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { right: '9%', top: '5%', feature: { - dataView: {}, + dataView: {readOnly: true}, magicType: { type: ['line', 'bar'] }, @@ -501,6 +501,10 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._chart = init(this._chartElem); // Set chart options to default this._chart.setOption(this._chartOptions); + // Make chart responsive + window.addEventListener("resize", () => this._chart!.resize()); + const resizeObserver = new ResizeObserver(() => this._chart!.resize()); + resizeObserver.observe(this._chartElem); } else { if (changedProperties.has("_data")) { From 4e5d3dafe18f6295fef2bf2663bee46bb0b52312 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Fri, 21 Feb 2025 12:47:16 +0100 Subject: [PATCH 09/15] remove restore button --- ui/component/or-attribute-history/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index e2a27664b3..f0279f8ee9 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -414,7 +414,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { magicType: { type: ['line', 'bar'] }, - restore: {}, saveAsImage: {} } }, From eb442e43a1aa6493cb4762222af9f41348460d49 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Mon, 24 Feb 2025 17:19:23 +0100 Subject: [PATCH 10/15] added data reload after zoom. ready for code cleanup --- .../or-attribute-history/src/index.ts | 71 ++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index f0279f8ee9..631bd7628c 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,6 +1,7 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; import {ECharts, EChartsOption, init} from "echarts"; +import _ from "lodash"; import { css, html, @@ -251,6 +252,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { @property() protected _loading: boolean = false; + @property() + protected _zoomChanged: boolean = false; + @property() protected _data?: ValueDatapoint[]; @@ -269,6 +273,10 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { protected _error?: string; protected _startOfPeriod?: number; protected _endOfPeriod?: number; + protected _queryStartOfPeriod?: number; + protected _queryEndOfPeriod?: number; + protected zoomStartPercentageOld: number = 0; + protected zoomEndPercentageOld: number = 100; //protected _timeUnits?: TimeUnit; Chart.js legacy protected _stepSize?: number; protected _updateTimestampTimer?: number; @@ -312,6 +320,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._loadData(); } + + return super.shouldUpdate(_changedProperties); } @@ -500,14 +510,16 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._chart = init(this._chartElem); // Set chart options to default this._chart.setOption(this._chartOptions); - // Make chart responsive + // Make chart size responsive window.addEventListener("resize", () => this._chart!.resize()); const resizeObserver = new ResizeObserver(() => this._chart!.resize()); resizeObserver.observe(this._chartElem); + // Add event listener for zooming + this._chart!.on('datazoom', _.debounce((params: any) => { this._onZoomChange(params); }, 1500)); } else { if (changedProperties.has("_data")) { - //Update chart data + //Update chart to data from set period this._chart.setOption({ xAxis: { min: this._startOfPeriod, @@ -712,8 +724,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { ]; } - protected async _loadData() { - if (this._data || !this.assetType || !this.assetId || (!this.attribute && !this.attributeRef) || !this.period || !this.toTimestamp) { + protected async _loadData() { + if ( (this._data && !this._zoomChanged) || !this.assetType || !this.assetId || (!this.attribute && !this.attributeRef) || !this.period || !this.toTimestamp) { return; } @@ -790,10 +802,14 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } const lowerCaseInterval = interval.toLowerCase(); - this._startOfPeriod = moment(this.toTimestamp).subtract(1, this.period).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); - this._endOfPeriod = moment(this.toTimestamp).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); - //this._timeUnits = lowerCaseInterval as TimeUnit; Chart.js legacy - this._stepSize = stepSize; + + if (!this._zoomChanged) { + // Set start and end of period based on the selected period + this._startOfPeriod = moment(this.toTimestamp).subtract(1, this.period).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); + this._endOfPeriod = moment(this.toTimestamp).startOf(lowerCaseInterval as moment.unitOfTime.StartOf).add(1, lowerCaseInterval as moment.unitOfTime.Base).toDate().getTime(); + this._queryStartOfPeriod = this._startOfPeriod; + this._queryEndOfPeriod = this._endOfPeriod; + } const isChart = this._type && (this._type.jsonType === "number" || this._type.jsonType === "boolean"); let response; @@ -803,8 +819,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { attributeName, { type: "lttb", - fromTimestamp: this._startOfPeriod, - toTimestamp: this._endOfPeriod, + fromTimestamp: this._queryStartOfPeriod, + toTimestamp: this._queryEndOfPeriod, amountOfPoints: 100 }, { signal: this._dataAbortController.signal } @@ -823,6 +839,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } this._loading = false; + this._zoomChanged = false; if (response.status === 200) { this._data = response.data @@ -865,6 +882,38 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this.toTimestamp = newMoment.toDate() }, timeout); } - + + protected _onZoomChange(params: any) { + + this._zoomChanged = true; + + const { start: zoomStartPercentage, end: zoomEndPercentage } = params.batch[0]; + + //DIT KAN VEEL KORTER DOOR SUBSTITUTIE + + const zoomStartTime = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); + const zoomEndTime = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); + + this._queryStartOfPeriod = zoomStartTime; + this._queryEndOfPeriod = zoomEndTime; + + + this._loadData().then(() => { + const data = this._data!.map(point => [point.x, point.y]); + + this._chart!.setOption({ + xAxis: { + min: this._startOfPeriod, + max: this._endOfPeriod + }, + series: [{ + data: data + }] + }); + }); + + this.zoomStartPercentageOld = zoomStartPercentage; + this.zoomEndPercentageOld = zoomEndPercentage; + } } From d8efb0e4c5df85d53fcd6ca66bcac37cee636383 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Mon, 24 Feb 2025 18:38:52 +0100 Subject: [PATCH 11/15] code cleanup & css fix --- .../or-attribute-history/src/index.ts | 66 ++++++------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 631bd7628c..ab8cd7d4c9 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -164,7 +164,7 @@ const style = css` } #time-picker { - width: 150px; + width: 130px; padding: 0 5px; } @@ -180,7 +180,7 @@ const style = css` } #ending-date { - width: 200px; + width: 220px; padding-left: 5px; } @@ -275,10 +275,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { protected _endOfPeriod?: number; protected _queryStartOfPeriod?: number; protected _queryEndOfPeriod?: number; - protected zoomStartPercentageOld: number = 0; - protected zoomEndPercentageOld: number = 100; - //protected _timeUnits?: TimeUnit; Chart.js legacy - protected _stepSize?: number; protected _updateTimestampTimer?: number; protected _dataAbortController?: AbortController; @@ -520,15 +516,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } else { if (changedProperties.has("_data")) { //Update chart to data from set period - this._chart.setOption({ - xAxis: { - min: this._startOfPeriod, - max: this._endOfPeriod - }, - series: [{ - data: data - }] - }); + this._updateChartData(); } } } else { @@ -776,28 +764,22 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { let interval: DatapointInterval = DatapointInterval.HOUR; - let stepSize = 1; switch (this.period) { case "hour": interval = DatapointInterval.MINUTE; - stepSize = 5; break; case "day": interval = DatapointInterval.HOUR; - stepSize = 1; break; case "week": interval = DatapointInterval.HOUR; - stepSize = 6; break; case "month": interval = DatapointInterval.DAY; - stepSize = 1; break; case "year": interval = DatapointInterval.MONTH; - stepSize = 1; break; } @@ -884,36 +866,30 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } protected _onZoomChange(params: any) { - this._zoomChanged = true; - const { start: zoomStartPercentage, end: zoomEndPercentage } = params.batch[0]; + //Define the start and end of the period based on the zoomed area + this._queryStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); + this._queryEndOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); - //DIT KAN VEEL KORTER DOOR SUBSTITUTIE - - const zoomStartTime = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); - const zoomEndTime = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); - - this._queryStartOfPeriod = zoomStartTime; - this._queryEndOfPeriod = zoomEndTime; - + this._loadData().then(() => { + this._updateChartData(); + }); - this._loadData().then(() => { - const data = this._data!.map(point => [point.x, point.y]); + } - this._chart!.setOption({ - xAxis: { - min: this._startOfPeriod, - max: this._endOfPeriod - }, - series: [{ - data: data - }] - }); - }); + protected _updateChartData(){ + const data = this._data!.map(point => [point.x, point.y]); - this.zoomStartPercentageOld = zoomStartPercentage; - this.zoomEndPercentageOld = zoomEndPercentage; + this._chart!.setOption({ + xAxis: { + min: this._startOfPeriod, + max: this._endOfPeriod + }, + series: [{ + data: data + }] + }); } } From 0911996adaaa2cf3c5946695b15d262ec87a635a Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Mar 2025 14:51:16 +0100 Subject: [PATCH 12/15] small improvements --- .../or-attribute-history/src/index.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index ab8cd7d4c9..f1188be95b 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -1,7 +1,7 @@ // Declare require method which we'll use for importing webpack resources (using ES6 imports will confuse typescript parser) declare function require(name: string): any; import {ECharts, EChartsOption, init} from "echarts"; -import _ from "lodash"; +import {debounce} from "lodash"; import { css, html, @@ -417,9 +417,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { top: '5%', feature: { dataView: {readOnly: true}, - magicType: { - type: ['line', 'bar'] - }, saveAsImage: {} } }, @@ -433,6 +430,18 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { showMinLabel: true, showMaxLabel: true, hideOverlap: true, + fontSize: 10, + formatter: { + year: '{yyyy}-{MMM}', + month: '{yy}-{MMM}', + day: '{d}-{MMM}', + hour: '{HH}:{mm}', + minute: '{HH}:{mm}', + second: '{HH}:{mm}:{ss}', + millisecond: '{d}-{MMM} {HH}:{mm}', + // @ts-ignore + none: '{MMM}-{dd} {HH}:{mm}' + } } }, yAxis: @@ -511,7 +520,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { const resizeObserver = new ResizeObserver(() => this._chart!.resize()); resizeObserver.observe(this._chartElem); // Add event listener for zooming - this._chart!.on('datazoom', _.debounce((params: any) => { this._onZoomChange(params); }, 1500)); + this._chart!.on('datazoom', debounce((params: any) => { this._onZoomChange(params); }, 1500)); } else { if (changedProperties.has("_data")) { @@ -867,7 +876,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { protected _onZoomChange(params: any) { this._zoomChanged = true; - const { start: zoomStartPercentage, end: zoomEndPercentage } = params.batch[0]; + const { start: zoomStartPercentage, end: zoomEndPercentage } = params.batch?.[0] ?? params; // Events triggered by scroll and zoombar return different structures //Define the start and end of the period based on the zoomed area this._queryStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); this._queryEndOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); @@ -887,7 +896,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { max: this._endOfPeriod }, series: [{ - data: data + data: data, + showSymbol: data.length <= 30 }] }); } From 9dec14d961103334b0466e5c599f30f9b2d257a7 Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Wed, 19 Mar 2025 14:58:23 +0100 Subject: [PATCH 13/15] remove now label --- ui/component/or-attribute-history/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index f1188be95b..49a561c70c 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -501,7 +501,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { markLine: { symbol: 'circle', silent: true, - data: [ { name: 'now', xAxis: new Date().toISOString(), label: {formatter: '{b}'}} ], + data: [ { name: '', xAxis: new Date().toISOString(), label: {formatter: '{b}'}} ], lineStyle: { color: this._style.getPropertyValue("--internal-or-attribute-history-text-color"), type: 'solid', From 18c1262cad3a4f61bd92b7b944c0f8a0ae7b713e Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Fri, 4 Apr 2025 16:26:13 +0200 Subject: [PATCH 14/15] update based on pr feedback --- .../or-attribute-history/src/index.ts | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index 49a561c70c..e8b84181e7 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -255,6 +255,9 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { @property() protected _zoomChanged: boolean = false; + @property() + protected _zoomReset: boolean = false; + @property() protected _data?: ValueDatapoint[]; @@ -314,10 +317,10 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { this._type = undefined; this._data = undefined; this._loadData(); + this._zoomReset = true; //Flag to avoid retrigger of loadData from zoom eventlistener triggered by next line. + this._chart?.dispatchAction({type: 'dataZoom', start: 0, end: 100}) } - - return super.shouldUpdate(_changedProperties); } @@ -398,7 +401,10 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { grid: { show: true, backgroundColor: this._style.getPropertyValue("--internal-or-asset-viewer-panel-color"), - borderColor: this._style.getPropertyValue("--internal-or-attribute-history-text-color") + borderColor: this._style.getPropertyValue("--internal-or-attribute-history-text-color"), + left: 15, + right: 15, + containLabel: true }, backgroundColor: this._style.getPropertyValue("--internal-or-asset-viewer-panel-color"), tooltip: { @@ -413,11 +419,15 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } }, toolbox: { - right: '9%', + right: '2%', top: '5%', feature: { dataView: {readOnly: true}, - saveAsImage: {} + saveAsImage: { + name: ['History', this.assetId, this.attribute?.name, `${new Date(this._startOfPeriod!)} - ${new Date(this._endOfPeriod!)}`] + .filter(Boolean) + .join(' ') + }, } }, xAxis: { @@ -427,8 +437,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { min: this._startOfPeriod, max: this._endOfPeriod, axisLabel: { - showMinLabel: true, - showMaxLabel: true, + //showMinLabel: true, + //showMaxLabel: true, hideOverlap: true, fontSize: 10, formatter: { @@ -497,16 +507,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { sampling: 'lttb', itemStyle: { color: this._style.getPropertyValue("--internal-or-attribute-history-graph-line-color") - }, - markLine: { - symbol: 'circle', - silent: true, - data: [ { name: '', xAxis: new Date().toISOString(), label: {formatter: '{b}'}} ], - lineStyle: { - color: this._style.getPropertyValue("--internal-or-attribute-history-text-color"), - type: 'solid', - width: 2 - } } } ] @@ -516,8 +516,8 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { // Set chart options to default this._chart.setOption(this._chartOptions); // Make chart size responsive - window.addEventListener("resize", () => this._chart!.resize()); - const resizeObserver = new ResizeObserver(() => this._chart!.resize()); + window.addEventListener("resize", () => this._chart?.resize()); + const resizeObserver = new ResizeObserver(() => this._chart?.resize()); resizeObserver.observe(this._chartElem); // Add event listener for zooming this._chart!.on('datazoom', debounce((params: any) => { this._onZoomChange(params); }, 1500)); @@ -875,16 +875,19 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { } protected _onZoomChange(params: any) { - this._zoomChanged = true; - const { start: zoomStartPercentage, end: zoomEndPercentage } = params.batch?.[0] ?? params; // Events triggered by scroll and zoombar return different structures - //Define the start and end of the period based on the zoomed area - this._queryStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); - this._queryEndOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); - - this._loadData().then(() => { - this._updateChartData(); - }); - + if (!this._zoomReset) { + this._zoomChanged = true; + console.log('onZoomChange'); + const {start: zoomStartPercentage, end: zoomEndPercentage} = params.batch?.[0] ?? params; // Events triggered by scroll and zoombar return different structures + //Define the start and end of the period based on the zoomed area + this._queryStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); + this._queryEndOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); + + this._loadData().then(() => { + this._updateChartData() + }); + } + this._zoomReset = false; } protected _updateChartData(){ From d73d3a575c500b22275d1539ac641238e7580a1e Mon Sep 17 00:00:00 2001 From: Hackerberg43 Date: Tue, 15 Apr 2025 11:20:39 +0200 Subject: [PATCH 15/15] pr tweaks --- ui/component/or-attribute-history/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/component/or-attribute-history/src/index.ts b/ui/component/or-attribute-history/src/index.ts index e8b84181e7..7bfa330274 100644 --- a/ui/component/or-attribute-history/src/index.ts +++ b/ui/component/or-attribute-history/src/index.ts @@ -424,7 +424,7 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { feature: { dataView: {readOnly: true}, saveAsImage: { - name: ['History', this.assetId, this.attribute?.name, `${new Date(this._startOfPeriod!)} - ${new Date(this._endOfPeriod!)}`] + name: ['History', this.assetId, this.attribute?.name, `${moment(this._startOfPeriod).format("DD-MM-YYYY HH:mm")} - ${moment(this._endOfPeriod).format("DD-MM-YYYY HH:mm")}`] .filter(Boolean) .join(' ') }, @@ -877,7 +877,6 @@ export class OrAttributeHistory extends translate(i18next)(LitElement) { protected _onZoomChange(params: any) { if (!this._zoomReset) { this._zoomChanged = true; - console.log('onZoomChange'); const {start: zoomStartPercentage, end: zoomEndPercentage} = params.batch?.[0] ?? params; // Events triggered by scroll and zoombar return different structures //Define the start and end of the period based on the zoomed area this._queryStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100);