diff --git a/ui/app/shared/locales/en/or.json b/ui/app/shared/locales/en/or.json index 15c11dbfee..ac55112ad6 100644 --- a/ui/app/shared/locales/en/or.json +++ b/ui/app/shared/locales/en/or.json @@ -40,8 +40,8 @@ "FR": "FR", "SA": "SA", "SU": "SU", - "left": "left", - "right": "right", + "left": "Left", + "right": "Right", "sceneStartTime": "Scene start time", "sceneConfiguration": "Scene Configuration", "temperature": "Temperature", @@ -141,7 +141,17 @@ "filterInvite": "Filter with at least 3 characters", "location": "Location", "longPressSetLoc": "Long press to set location", + "this": "This", + "last": "Last", + "5Minutes": "5 minutes", + "20Minutes": "20 minutes", + "60Minutes": "60 minutes", "hour": "Hour", + "6Hours": "6 hours", + "24Hours": "24 hours", + "7Days": "7 days", + "30Days": "30 days", + "365Days": "365 days", "lastHour": "Last hour", "last24Hours": "Last 24 hours", "thisHour": "This hour", @@ -165,6 +175,8 @@ "previousYear": "Previous year", "period": "Period", "ending": "Ending", + "prefix": "Prefix", + "prefixDefault": "Default Prefix", "timeframe": "Timeframe", "timeframeDefault": "Default timeframe", "dataSampling": "Data sampling", @@ -770,6 +782,8 @@ "showLegend": "Show legend", "showGeoJson": "Show GeoJSON overlay", "showValueAs": "Show value as", + "showZoomBar": "Show zoom bar", + "showToolBox": "Show toolbox", "defaultStacked": "Stack by default", "showToolBox": "Show Toolbox", "isChart": "Bar chart instead of table", @@ -785,6 +799,15 @@ "center": "Center", "imageUrl": "Image URL", "noImageSelected": "No image selected", + "faint": "Faint", + "smooth": "Smooth", + "stepped": "Stepped", + "fill": "Fill", + "extendData": "Extend latest datapoint", + "lastKnown": "(last known)", + "lineColor": "Line color", + "maxConcurrentDatapoints": "Max concurrent datapoints", + "showSymbolMaxDatapoints": "Upper limit to show datapoint symbols" "noData": "No data", "noDataOrMethod": "No data or no method per interval selected for selected attributes" }, diff --git a/ui/component/or-chart/src/index.ts b/ui/component/or-chart/src/index.ts index 4503581192..5a376cac9a 100644 --- a/ui/component/or-chart/src/index.ts +++ b/ui/component/or-chart/src/index.ts @@ -20,43 +20,28 @@ import "@openremote/or-asset-tree"; import "@openremote/or-mwc-components/or-mwc-input"; import "@openremote/or-components/or-panel"; import "@openremote/or-translate"; +import {ECharts, EChartsOption, init, graphic} from "echarts"; 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 {debounce, 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"; @@ -73,8 +58,6 @@ export class OrChartEvent extends CustomEvent { } } -export type TimePresetCallback = (date: Date) => [Date, Date]; - export interface ChartViewConfig { attributeRefs?: AttributeRef[]; fromTimestamp?: number; @@ -125,7 +108,7 @@ const style = css` --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-fill-opacity: var(--or-chart-graph-fill-opacity, 0.25); --internal-or-chart-graph-line-color: var(--or-chart-graph-line-color, var(--or-app-color4, ${unsafeCSS(DefaultColor4)})); --internal-or-chart-graph-point-color: var(--or-chart-graph-point-color, var(--or-app-color3, ${unsafeCSS(DefaultColor3)})); --internal-or-chart-graph-point-border-color: var(--or-chart-graph-point-border-color, var(--or-app-color5, ${unsafeCSS(DefaultColor5)})); @@ -313,7 +296,7 @@ const style = css` flex-direction: column; align-items: center; } - canvas { + #chart { width: 100% !important; height: 100%; !important; } @@ -372,11 +355,29 @@ export class OrChart extends translate(i18next)(LitElement) { @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({type: Array}) + public colorPickedAttributes: Array<{ attributeRef: AttributeRef; color: string }> = []; + + @property({type: Object}) + public attributeSettings: { + rightAxisAttributes: AttributeRef[], + smoothAttributes: AttributeRef[], + steppedAttributes: AttributeRef[], + areaAttributes: AttributeRef[], + faintAttributes: AttributeRef[], + extendedAttributes: AttributeRef[], + } = { + rightAxisAttributes: [], + smoothAttributes: [], + steppedAttributes: [], + areaAttributes: [], + faintAttributes: [], + extendedAttributes: [], + }; + @property() - public dataProvider?: (startOfPeriod: number, endOfPeriod: number, timeUnits: TimeUnit, stepSize: number) => Promise[]> + 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"]; @@ -387,7 +388,7 @@ export class OrChart extends translate(i18next)(LitElement) { @property({type: Object}) public config?: OrChartConfig; - @property({type: Object}) // options that will get merged with our default chartjs configuration. + @property({type: Object}) public chartOptions?: any @property({type: String}) @@ -406,10 +407,19 @@ export class OrChart extends translate(i18next)(LitElement) { public timestampControls: boolean = true; @property() - public timePresetOptions?: Map; + protected timePrefixOptions?: string[]; @property() - public timePresetKey?: string; + public timeWindowOptions?: Map; + + @property() + public timePrefixKey?: string; + + @property() + public timeWindowKey?: string; + + + @property() public showLegend: boolean = true; @@ -417,26 +427,46 @@ export class OrChart extends translate(i18next)(LitElement) { @property() public denseLegend: boolean = false; + @property() + public showZoomBar: boolean = true; + + @property() + public showToolBox: boolean = true; + + @property() + public showSymbolMaxDatapoints: number = 30; + + @property() + public maxConcurrentDatapoints: number = 100; + @property() protected _loading: boolean = false; @property() - protected _data?: ChartDataset<"line", ScatterDataPoint[]>[] = undefined; + protected _zoomChanged: boolean = false; + + @property() + protected _data?: ValueDatapoint[]; @property() protected _tableTemplate?: TemplateResult; @query("#chart") - protected _chartElem!: HTMLCanvasElement; - - protected _chart?: Chart; + protected _chartElem!: HTMLDivElement; + protected _chartOptions: EChartsOption = {}; + protected _chart?: ECharts; protected _style!: CSSStyleDeclaration; protected _startOfPeriod?: number; protected _endOfPeriod?: number; + protected _zoomStartOfPeriod?: number; + protected _zoomEndOfPeriod?: number; protected _timeUnits?: TimeUnit; protected _stepSize?: number; protected _latestError?: string; protected _dataAbortController?: AbortController; + protected _zoomHandler?: any; + protected _resizeHandler?: any; + protected _containerResizeObserver?: ResizeObserver; constructor() { super(); @@ -451,6 +481,7 @@ export class OrChart extends translate(i18next)(LitElement) { disconnectedCallback(): void { super.disconnectedCallback(); this._cleanup(); + } firstUpdated() { @@ -458,6 +489,7 @@ export class OrChart extends translate(i18next)(LitElement) { } updated(changedProperties: PropertyValues) { + super.updated(changedProperties); if (changedProperties.has("realm")) { @@ -467,13 +499,15 @@ export class OrChart extends translate(i18next)(LitElement) { } } - const reloadData = changedProperties.has("datapointQuery") || changedProperties.has("timePresetKey") || changedProperties.has("timeframe") || - changedProperties.has("rightAxisAttributes") || changedProperties.has("assetAttributes") || changedProperties.has("realm") || changedProperties.has("dataProvider"); + const reloadData = changedProperties.has('colorPickedAttributes') || changedProperties.has("datapointQuery") || changedProperties.has("timeframe") || changedProperties.has("timePrefixKey") || changedProperties.has("timeWindowKey")|| + changedProperties.has("attributeSettings") || changedProperties.has("assetAttributes") || changedProperties.has("realm") || changedProperties.has("dataProvider"); if (reloadData) { this._data = undefined; if (this._chart) { - this._chart.destroy(); + // Remove event listeners + this._toggleChartEventListeners(false); + this._chart.dispose(); this._chart = undefined; } this._loadData(); @@ -483,117 +517,156 @@ export class OrChart extends translate(i18next)(LitElement) { return; } - const now = moment().toDate().getTime(); - if (!this._chart) { - const options = { - type: "line", - data: { - datasets: this._data + + let bgColor = this._style.getPropertyValue("--internal-or-chart-graph-fill-color").trim(); + const opacity = Number(this._style.getPropertyValue("--internal-or-chart-graph-fill-opacity").trim()); + if (!isNaN(opacity)) { + if (bgColor.startsWith("#") && (bgColor.length === 4 || bgColor.length === 7)) { + bgColor += (bgColor.length === 4 ? Math.round(opacity * 255).toString(16).substr(0, 1) : Math.round(opacity * 255).toString(16)); + } else if (bgColor.startsWith("rgb(")) { + bgColor = bgColor.substring(0, bgColor.length - 1) + opacity; + } + } + + + + this._chartOptions = { + animation: false, + grid: { + show: true, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-tree-background-color"), + borderColor: this._style.getPropertyValue("--internal-or-chart-text-color"), + left: 50,//'5%', // 5% padding + right: 50,//'5%', + top: this.showToolBox ? 28 : 10, + bottom: this.showZoomBar ? 68 : 20 }, - 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 - } - ] - }, + backgroundColor: this._style.getPropertyValue("--internal-or-asset-tree-background-color"), + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' }, - hover: { - mode: 'x', - intersect: false + }, + toolbox: {}, + xAxis: { + type: 'time', + axisLine: { + onZero: false, + lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")} }, - scales: { - y: { - ticks: { - beginAtZero: true - }, - grid: { - color: "#cccccc" - } - }, - y1: { - display: this.rightAxisAttributes.length > 0, - position: 'right', - ticks: { - beginAtZero: true - }, - grid: { - drawOnChartArea: false - } + splitLine: {show: true}, + min: this._startOfPeriod, + max: this._endOfPeriod, + axisLabel: { + 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: [ + { + type: 'value', + axisLine: { lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")}}, + boundaryGap: ['10%', '10%'], + scale: true, + min: this.chartOptions.options.scales.y.min ? this.chartOptions.options.scales.y.min : undefined, //NOG FIXEN MET MERGEN VAN CHARTOPTIONS + max: this.chartOptions.options.scales.y.max ? this.chartOptions.options.scales.y.max : undefined + }, + { + type: 'value', + show: this.attributeSettings.rightAxisAttributes.length > 0, + axisLine: { lineStyle: {color: this._style.getPropertyValue("--internal-or-chart-text-color")}}, + boundaryGap: ['10%', '10%'], + scale: true, + min: this.chartOptions.options.scales.y1.min ? this.chartOptions.options.scales.y1.min : undefined, + max: this.chartOptions.options.scales.y1.max ? this.chartOptions.options.scales.y1.max : undefined + } + ], + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100 + } + ], + series: [], + }; + + // Add dataZoom bar if enabled + if(this.showZoomBar) { + (this._chartOptions!.dataZoom! as any[]).push({ + start: 0, + end: 100, + backgroundColor: bgColor, + fillerColor: bgColor, + dataBackground: { + areaStyle: { + color: this._style.getPropertyValue("--internal-or-chart-graph-fill-color") + } + }, + selectedDataBackground: { + areaStyle: { + color: this._style.getPropertyValue("--internal-or-chart-graph-fill-color"), + } + }, + moveHandleStyle: { + color: this._style.getPropertyValue("--internal-or-chart-graph-fill-color") + }, + emphasis: { + moveHandleStyle: { + color: this._style.getPropertyValue("--internal-or-chart-graph-fill-color") }, - 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" - } + handleLabel: { + show: false } + }, + handleLabel: { + show: false + } + }) + } + + // Add toolbox if enabled + if(this.showToolBox) { + this._chartOptions!.toolbox! = { + right: 45, + top: 0, + feature: { + dataView: {readOnly: true}, + //magicType: { + // type: ['line', 'bar'] + //}, + saveAsImage: {} } } - } as ChartConfiguration<"line", ScatterDataPoint[]>; + } - const mergedOptions = Util.mergeObjects(options, this.chartOptions, false); + // Initialize echarts instance + this._chart = init(this._chartElem); + // Set chart options to default + this._chart.setOption(this._chartOptions); + this._toggleChartEventListeners(true); + } - 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(); - } + if (changedProperties.has("_data")) { + //Update chart to data from set period + this._updateChartData(); } + this.onCompleted().then(() => { this.dispatchEvent(new OrChartEvent('rendered')); }); @@ -631,7 +704,8 @@ export class OrChart extends translate(i18next)(LitElement) { } render() { - const disabled = this._loading || this._latestError; + + const disabled = false; // TEMP EDIT this._loading || this._latestError; return html`
@@ -645,32 +719,49 @@ export class OrChart extends translate(i18next)(LitElement) {
`)} - +
${(this.timestampControls || this.attributeControls || this.showLegend) ? html`
- ${this.timePresetOptions && this.timePresetKey ? html` + ${this.timePrefixKey && this.timePrefixOptions && this.timeWindowKey && this.timeWindowOptions ? html` ${this.timestampControls ? html` + + + + ${getContentWithMenuTemplate( + html``, + this.timePrefixOptions.map((option) => ({ value: option } as ListItem)), + this.timePrefixKey, + (value: string | string[]) => { + this.timeframe = undefined; // remove any custom start & end times + this.timePrefixKey = value.toString(); + }, + undefined, + undefined, + undefined, + true + )} + ${getContentWithMenuTemplate( - html``, - Array.from(this.timePresetOptions!.keys()).map((key) => ({ value: key } as ListItem)), - this.timePresetKey, + html``, + Array.from(this.timeWindowOptions!.keys()).map((key) => ({ value: key } as ListItem)), + this.timeWindowKey, (value: string | string[]) => { this.timeframe = undefined; // remove any custom start & end times - this.timePresetKey = value.toString(); + this.timeWindowKey = value.toString(); }, undefined, undefined, undefined, true )} - - + + ` : html` - + `} ` : undefined}
@@ -706,12 +797,13 @@ export class OrChart extends translate(i18next)(LitElement) { ${this.assetAttributes && this.assetAttributes.map(([assetIndex, attr], index) => { const asset: Asset | undefined = this.assets[assetIndex]; const colourIndex = index % this.colors.length; + const color = this.colorPickedAttributes.find(({ attributeRef }) => attributeRef.name === attr.name && attributeRef.id === asset.id)?.color; const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset!.type, attr.name, attr); const label = Util.getAttributeLabel(attr, descriptors[0], asset!.type, true); - const axisNote = (this.rightAxisAttributes.find(ar => asset!.id === ar.id && attr.name === ar.name)) ? i18next.t('right') : undefined; - const bgColor = this.colors[colourIndex] || ""; + const axisNote = (this.attributeSettings.rightAxisAttributes.find(ar => asset!.id === ar.id && attr.name === ar.name)) ? i18next.t('right') : undefined; + const bgColor = ( color ?? this.colors[colourIndex] ) || ""; return html` -
+
${getAssetDescriptorIconTemplate(AssetModelUtil.getAssetDescriptor(this.assets[assetIndex]!.type!), undefined, undefined, bgColor.split('#')[1])}
@@ -751,32 +843,44 @@ export class OrChart extends translate(i18next)(LitElement) { } } - 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(); + removeDatasetHighlight() { + if(this._chart){ + let options = this._chart.getOption(); + if (options.series && Array.isArray(options.series)) { + options.series.forEach(function (series) { + if (series.lineStyle.opacity == 0.2 || series.lineStyle.opacity == 0.99) { + series.lineStyle.opacity = 0.31; + } else { + series.lineStyle.opacity = 1; + } + }); + } + this._chart.setOption(options); } } 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(); + if (this._chart) { + let options = this._chart.getOption(); + if (options.series && Array.isArray(options.series)) { + options.series.forEach(function (series) { + if (series.assetId != assetId || series.attrName != attrName) { + if (series.lineStyle.opacity == 0.31) { // 0.31 is faint setting, 1 is normal + series.lineStyle.opacity = 0.2; + } else { + series.lineStyle.opacity = 0.3; + } + } else if (series.lineStyle.opacity == 0.31) { // extra highlight if selected is faint + series.lineStyle.opacity = 0.99; + } + }); + } + this._chart.setOption(options) } - } + }; + + + async loadSettings(reset: boolean) { @@ -788,11 +892,20 @@ export class OrChart extends translate(i18next)(LitElement) { this.realm = manager.getRealm(); } - if (!this.timePresetOptions) { - this.timePresetOptions = this._getDefaultTimestampOptions(); + if (!this.timePrefixOptions) { + this.timePrefixOptions = this._getDefaultTimePrefixOptions(); } - if (!this.timePresetKey) { - this.timePresetKey = this.timePresetOptions.keys().next().value.toString(); + + if (!this.timeWindowOptions) { + this.timeWindowOptions = this._getDefaultTimeWindowOptions(); + } + + if (!this.timeWindowKey) { + this.timeWindowKey = this.timeWindowOptions.keys().next().value.toString(); + } + + if (!this.timePrefixKey) { + this.timePrefixKey = this.timePrefixOptions[1]; } if (!this.panelName) { @@ -863,7 +976,6 @@ export class OrChart extends translate(i18next)(LitElement) { } async saveSettings() { - if (!this.panelName) { return; } @@ -908,31 +1020,6 @@ export class OrChart extends translate(i18next)(LitElement) { 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; @@ -966,7 +1053,9 @@ export class OrChart extends translate(i18next)(LitElement) { protected _cleanup() { if (this._chart) { - this._chart.destroy(); + //('cleanup found _chart exists so disposing'); + this._toggleChartEventListeners(false); + this._chart.dispose(); this._chart = undefined; this.requestUpdate(); } @@ -989,6 +1078,7 @@ export class OrChart extends translate(i18next)(LitElement) { } protected _getAttributeOptionsOld(): [string, string][] | undefined { + //('getAttributeOptionsOld triggered'); if(!this.activeAsset || !this.activeAsset.attributes) { return; } @@ -1034,18 +1124,82 @@ export class OrChart extends translate(i18next)(LitElement) { } } - 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 _getDefaultTimePrefixOptions(): string[] { + return ["this", "last"]; + } + + + protected _getDefaultTimeWindowOptions(): Map { + return new Map([ + ["hour", ['hours', 1]], + ["6Hours", ['hours', 6]], + ["24Hours", ['hours', 24]], + ["day", ['days', 1]], + ["7Days", ['days', 7]], + ["week", ['weeks', 1]], + ["30Days", ['days', 30]], + ["month", ['months', 1]], + ["365Days", ['days', 365]], + ["year", ['years', 1]] ]); + }; + + + protected _getTimeWindowSelected(timePrefixSelected: string, timeWindowSelected: string): [Date, Date] { + let startDate = moment(); + let endDate = moment(); + + const timeWindow: [moment.unitOfTime.DurationConstructor, number] | undefined = this.timeWindowOptions!.get(timeWindowSelected); + + if (!timeWindow) { + throw new Error(`Unsupported time window selected: ${timeWindowSelected}`); + } + + const [unit , value]: [moment.unitOfTime.DurationConstructor, number] = timeWindow; + + switch (timePrefixSelected) { + case "this": + if (value == 1) { // For singulars like this hour + startDate = moment().startOf(unit); + endDate = moment().endOf(unit); + } else { // For multiples like this 5 min, put now in the middle + startDate = moment().subtract(value*0.5, unit); + endDate = moment().add(value*0.5, unit); + } + break; + case "last": + startDate = moment().subtract(value, unit).startOf(unit); + if (value == 1) { // For singulars like last hour + endDate = moment().startOf(unit); + } else { //For multiples like last 5 min + endDate = moment(); + } + break; + } + return [startDate.toDate(), endDate.toDate()]; + } + + protected _shiftTimeframe(currentStart: Date, timeWindowSelected: string, direction: string) { + const timeWindow = this.timeWindowOptions!.get(timeWindowSelected); + + if (!timeWindow) { + throw new Error(`Unsupported time window selected: ${timeWindowSelected}`); + } + + const [unit, value] = timeWindow; + let newStart = moment(currentStart); + + direction === "previous" ? newStart.subtract(value, unit as moment.unitOfTime.DurationConstructor) : newStart.add(value, unit as moment.unitOfTime.DurationConstructor); + + let newEnd = moment(newStart).add(value, unit as moment.unitOfTime.DurationConstructor); + + this.timeframe = [newStart.toDate(), newEnd.toDate()]; } + protected _getInterval(diffInHours: number): [number, DatapointInterval] { if(diffInHours <= 1) { @@ -1068,7 +1222,7 @@ export class OrChart extends translate(i18next)(LitElement) { } protected async _loadData() { - if (this._data || !this.assetAttributes || !this.assets || (this.assets.length === 0 && !this.dataProvider) || (this.assetAttributes.length === 0 && !this.dataProvider) || !this.datapointQuery) { + if ((this._data && !this._zoomChanged) || !this.assetAttributes || !this.assets || (this.assets.length === 0 && !this.dataProvider) || (this.assetAttributes.length === 0 && !this.dataProvider) || !this.datapointQuery) { return; } @@ -1083,9 +1237,13 @@ export class OrChart extends translate(i18next)(LitElement) { 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 dates: [Date, Date] = this._getTimeWindowSelected(this.timePrefixKey!, this.timeWindowKey!); + + if(!this._zoomChanged || !this._startOfPeriod || !this._endOfPeriod) { + // If zoom has changed, we want to keep the previous start and end of period + 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); @@ -1099,11 +1257,11 @@ export class OrChart extends translate(i18next)(LitElement) { const now = moment().toDate().getTime(); let predictedFromTimestamp = now < this._startOfPeriod ? this._startOfPeriod : now; - const data: ChartDataset<"line", ScatterDataPoint[]>[] = []; + const data: any = []; let promises; try { - if(this.dataProvider) { + if(this.dataProvider && !this._zoomChanged) { await this.dataProvider(this._startOfPeriod, this._endOfPeriod, (interval.toString() as TimeUnit), stepSize).then((dataset) => { dataset.forEach((set) => { data.push(set); }); }); @@ -1112,22 +1270,39 @@ export class OrChart extends translate(i18next)(LitElement) { 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 shownOnRightAxis = !!this.attributeSettings.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const smooth = !!this.attributeSettings.smoothAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const stepped = !!this.attributeSettings.steppedAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const area = !!this.attributeSettings.areaAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const faint = !!this.attributeSettings.faintAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const extended = !!this.attributeSettings.extendedAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name); + const color = this.colorPickedAttributes.find(({ attributeRef }) => attributeRef.name === attribute.name && attributeRef.id === asset.id)?.color; const descriptors = AssetModelUtil.getAttributeAndValueDescriptors(asset.type, attribute.name, attribute); const label = Util.getAttributeLabel(attribute, descriptors[0], asset.type, false); const unit = Util.resolveUnits(Util.getAttributeUnits(attribute, descriptors[0], asset.type)); const colourIndex = index % this.colors.length; const options = { signal: this._dataAbortController?.signal }; - let dataset = await this._loadAttributeData(asset, attribute, this.colors[colourIndex], this._startOfPeriod!, this._endOfPeriod!, false, asset.name + " " + label, options); + //Load Historic Data + let dataset = await this._loadAttributeData(asset, attribute, color ?? this.colors[colourIndex], this._startOfPeriod!, this._endOfPeriod!, false, smooth, stepped, area, faint, false, asset.name + " " + label, options, unit); (dataset as any).assetId = asset.id; (dataset as any).attrName = attribute.name; (dataset as any).unit = unit; - (dataset as any).yAxisID = shownOnRightAxis ? 'y1' : 'y'; + (dataset as any).yAxisIndex = shownOnRightAxis ? '1' : '0'; + (dataset as any).color = color ?? this.colors[colourIndex]; 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); - (dataset as any).unit = unit; + //Load Predicted Data + dataset = await this._loadAttributeData(this.assets[assetIndex], attribute, color ?? this.colors[colourIndex], predictedFromTimestamp, this._endOfPeriod!, true, smooth, stepped, area, faint, false , asset.name + " " + label + " " + i18next.t("predicted"), options, unit); data.push(dataset); + //Load Extended Data + let bsNumber = 1; //inserted in from and to, however these are not used in _loadAttributeData anyway, the function references variables outside of it (bad practice) + if (extended) { + dataset = await this._loadAttributeData(this.assets[assetIndex], attribute, color ?? this.colors[colourIndex], bsNumber, bsNumber, false, false, false, area, faint, extended, asset.name + " " + label + " " + i18next.t("dashboard.lastKnown"), options, unit); + data.push(dataset); + } + + //Is it actually efficient to query three times ? think this can be way more efficient. + + }); } @@ -1137,6 +1312,7 @@ export class OrChart extends translate(i18next)(LitElement) { this._data = data; this._loading = false; + this._zoomChanged = false; } catch (ex) { console.error(ex); @@ -1144,6 +1320,7 @@ export class OrChart extends translate(i18next)(LitElement) { return; // If request has been canceled (using AbortController); return, and prevent _loading is set to false. } this._loading = false; + this._zoomChanged = false; if(isAxiosError(ex)) { if(ex.message.includes("timeout")) { @@ -1159,40 +1336,60 @@ export class OrChart extends translate(i18next)(LitElement) { } - protected async _loadAttributeData(asset: Asset, attribute: Attribute, color: string | undefined, from: number, to: number, predicted: boolean, label?: string, options?: any): Promise> { + protected async _loadAttributeData(asset: Asset, attribute: Attribute, color: string, from: number, to: number, predicted: boolean, smooth: boolean, stepped: boolean, area: boolean, faint: boolean, extended: boolean, label?: string, options?: any, unit?: any) { + + function rgba (color: string, alpha: number) { + return `rgba(${parseInt(color.slice(-6,-4), 16)}, ${parseInt(color.slice(-4,-2), 16)}, ${parseInt(color.slice(-2), 16)}, ${alpha})`; + } + + const dataset = { + name: label, + type: 'line', + showSymbol: false, + data: [] as [any, any][], + sampling: 'lttb', + lineStyle: { + color: color, + type: predicted ? [2, 4] : extended ? [0.8, 10] : undefined, + opacity: faint ? 0.31 : 1, + }, + itemStyle: { + color: color + }, + tooltip: { + // @ts-ignore + valueFormatter: value => value + unit + }, + smooth: smooth, + step: stepped ? 'end' : undefined, + areaStyle: area ? {color: new graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: rgba(color, faint ? 0.1 : 0.5) + }, + { + offset: 1, + color: rgba(color, 0) + } + ])} as any : undefined, + } - 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 (!this._zoomChanged) { + query.fromTimestamp = this._startOfPeriod; + query.toTimestamp = this._endOfPeriod; + } else { + query.fromTimestamp = this._zoomStartOfPeriod; + query.toTimestamp = this._zoomEndOfPeriod; + } - // 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; - } - } + if(query.type == 'lttb') { + // If the query type is lttb, we need to limit the amount of points to the maxConcurrentDatapoints + query.amountOfPoints = this.maxConcurrentDatapoints; } else if(query.type === 'interval' && !query.interval) { const diffInHours = (this.datapointQuery.toTimestamp! - this.datapointQuery.fromTimestamp!) / 1000 / 60 / 60; @@ -1200,18 +1397,117 @@ export class OrChart extends translate(i18next)(LitElement) { 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 (predicted) { + response = await manager.rest.api.AssetPredictedDatapointResource.getPredictedDatapoints(asset.id, attribute.name, query, options); + } else { + if (extended) { + // if request is for extended dataset, we want to get the last known value only + query.type = 'nearest'; + query.timestamp = new Date().toISOString() + } + + response = await manager.rest.api.AssetDatapointResource.getDatapoints(asset.id, attribute.name, query, options); + } + + let data: ValueDatapoint[] = []; if (response.status === 200) { - dataset.data = response.data.filter(value => value.y !== null && value.y !== undefined) as ScatterDataPoint[]; + data = response.data + .filter(value => value.y !== null && value.y !== undefined) + .map(point => ({ x: point.x, y: point.y } as ValueDatapoint)) + + dataset.data = data.map(point => [point.x, point.y]); + dataset.showSymbol = data.length <= this.showSymbolMaxDatapoints; } - } + + if (extended) { + if (dataset.data.length > 0) { + // Get the first datapoint's timestamp + const firstPointTime = new Date(dataset.data[0][0]).getTime(); + + // If the first point is earlier than startOfPeriod, use startOfPeriod as the starting timestamp + const startTimestamp = firstPointTime < query.fromTimestamp! ? + new Date(query.fromTimestamp!).toISOString() : + dataset.data[0][0]; + + // Use endOfPeriod if it's earlier than now, otherwise use the current time + const now = new Date().getTime(); + const endTimestamp = query.toTimestamp! < now ? + new Date(query.toTimestamp!).toISOString() : + new Date().toISOString(); + // Create a clean extended line by removing any existing points and adding just two points: + // One at the appropriate start time and one at the current time + dataset.data = [ + [startTimestamp, dataset.data[0][1]], + [endTimestamp, dataset.data[0][1]] + ]; + } + } + } return dataset; } + 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._zoomStartOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomStartPercentage / 100); + this._zoomEndOfPeriod = this._startOfPeriod! + ((this._endOfPeriod! - this._startOfPeriod!) * zoomEndPercentage / 100); + this._loadData().then(() => { + this._updateChartData(); + }); + + } + + protected _updateChartData(){ + this._chart!.setOption({ + xAxis: { + min: this._startOfPeriod, + max: this._endOfPeriod + }, + series: this._data!.map(series => ({ + ...series, + markLine: { + symbol: 'circle', + silent: true, + data: [{ name: '', xAxis: new Date().toISOString(), label: { formatter: '{b}' } }], + lineStyle: { + color: this._style.getPropertyValue("--internal-or-chart-text-color"), + type: 'solid', + width: 2, + opacity: 1 + } + } + })) + });; + } + + protected _toggleChartEventListeners(connect: boolean){ + if (connect) { + //Connect event listeners + // Make chart size responsive + //window.addEventListener("resize", () => this._chart!.resize()); + this._containerResizeObserver = new ResizeObserver(() => this._chart!.resize()); + this._containerResizeObserver.observe(this._chartElem); + // Add event listener for zooming + this._zoomHandler = this._chart!.on('datazoom', debounce((params: any) => { this._onZoomChange(params); }, 1500)); + // Add event listener for chart resize + this._resizeHandler = this._chart!.on('resize', throttle(() => { this.applyChartResponsiveness(); }, 200)); + } + else if (!connect) { + //Disconnect event listeners + this._chart!.off('datazoom', this._zoomHandler); + this._chart!.off('resize', this._resizeHandler); + this._containerResizeObserver?.disconnect(); + this._containerResizeObserver = undefined; + } + + + + + + } } diff --git a/ui/component/or-dashboard-builder/src/settings/chart-settings.ts b/ui/component/or-dashboard-builder/src/settings/chart-settings.ts index f0c7cb4350..e32e61d3b7 100644 --- a/ui/component/or-dashboard-builder/src/settings/chart-settings.ts +++ b/ui/component/or-dashboard-builder/src/settings/chart-settings.ts @@ -8,8 +8,8 @@ import {AttributeAction, AttributeActionEvent, AttributesSelectEvent} from "../p import {Asset, AssetDatapointIntervalQuery, AssetDatapointIntervalQueryFormula, Attribute, AttributeRef} from "@openremote/model"; import {ChartWidgetConfig} from "../widgets/chart-widget"; import {InputType, OrInputChangedEvent} from "@openremote/or-mwc-components/or-mwc-input"; -import {TimePresetCallback} from "@openremote/or-chart"; import {when} from "lit/directives/when.js"; +import moment from "moment/moment"; const styling = css` .switch-container { @@ -23,13 +23,20 @@ const styling = css` @customElement("chart-settings") export class ChartSettings extends WidgetSettings { - protected readonly widgetConfig!: ChartWidgetConfig + protected readonly widgetConfig!: ChartWidgetConfig; - protected timePresetOptions: Map = new Map(); + + + protected timeWindowOptions: Map = new Map; + protected timePrefixOptions: string[] = []; protected samplingOptions: Map = new Map(); - public setTimePresetOptions(options: Map) { - this.timePresetOptions = options; + public setTimeWindowOptions(options: Map) { + this.timeWindowOptions = options; + } + + public setTimePrefixOptions(options: string[]) { + this.timePrefixOptions = options; } public setSamplingOptions(options: Map) { @@ -44,26 +51,87 @@ export class ChartSettings extends WidgetSettings { const attributeFilter: (attr: Attribute) => boolean = (attr): boolean => { return ["boolean", "positiveInteger", "positiveNumber", "number", "long", "integer", "bigInteger", "negativeInteger", "negativeNumber", "bigNumber", "integerByte", "direction"].includes(attr.type!) }; + const attrSettings = this.widgetConfig.attributeSettings; const min = this.widgetConfig.chartOptions.options?.scales?.y?.min; const max = this.widgetConfig.chartOptions.options?.scales?.y?.max; - const isMultiAxis = this.widgetConfig.rightAxisAttributes.length > 0; + const isMultiAxis = attrSettings.rightAxisAttributes.length > 0; const samplingValue = Array.from(this.samplingOptions.entries()).find((entry => entry[1] === this.widgetConfig.datapointQuery.type))![0] const attributeLabelCallback = (asset: Asset, attribute: Attribute, attributeLabel: string) => { - const isOnRightAxis = isMultiAxis && this.widgetConfig.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isOnRightAxis = isMultiAxis && attrSettings.rightAxisAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isFaint = attrSettings.faintAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isSmooth = attrSettings.smoothAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isStepped = attrSettings.steppedAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isArea = attrSettings.areaAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; + const isExtended = attrSettings.extendedAttributes.find(ar => ar.id === asset.id && ar.name === attribute.name) !== undefined; return html` ${asset.name} ${attributeLabel} ${when(isOnRightAxis, () => html` - + + `)} + ${when(isFaint, () => html` + + `)} + ${when(isSmooth, () => html` + + `)} + ${when(isStepped, () => html` + + `)} + ${when(isArea, () => html` + + `)} + ${when(isExtended, () => html` + `)} ` } + + const attributeActionCallback = (attributeRef: AttributeRef): AttributeAction[] => { - return [{ - icon: this.widgetConfig.rightAxisAttributes.includes(attributeRef) ? "arrow-right-bold" : "arrow-left-bold", - tooltip: i18next.t('dashboard.toggleAxis'), - disabled: false - }] + return [ + { + icon: 'palette', + tooltip: i18next.t('dashboard.lineColor'), + disabled: false + }, + { + icon: 'chart-bell-curve-cumulative', + tooltip: i18next.t("dashboard.smooth"), + disabled: false + }, + { + icon: 'square-wave', + tooltip: i18next.t('dashboard.stepped'), + disabled: false + }, + { + icon: 'chart-areaspline-variant', + tooltip: i18next.t('dashboard.fill'), + disabled: false + }, + { + icon: 'arrange-send-backward', + tooltip: i18next.t('dashboard.faint'), + disabled: false + }, + { + icon: 'arrow-expand-right', + tooltip: i18next.t('dashboard.extendData'), + disabled: false + }, + + { + icon: this.widgetConfig.attributeSettings.rightAxisAttributes.includes(attributeRef) ? "arrow-right-bold" : "arrow-left-bold", + tooltip: i18next.t('dashboard.toggleAxis'), + disabled: false + }, + { + icon: 'mdi-blank', + tooltip: '', + disabled: true + } + ] } return html`
@@ -76,17 +144,21 @@ export class ChartSettings extends WidgetSettings { > - - + +
+
- +
@@ -94,18 +166,43 @@ export class ChartSettings extends WidgetSettings { @or-mwc-input-changed="${(ev: OrInputChangedEvent) => this.onTimestampControlsToggle(ev)}" >
+
+
+
+ + +
-
+ +
+ + +
+ +
+ + +
+ +
+ +
- +
@@ -182,7 +279,7 @@ export class ChartSettings extends WidgetSettings { - +
`; } + case 'lttb': { + return html ` + + `; + } default: return html``; } } - // When a user clicks on ANY action in the attribute list, we want to switch between LEFT and RIGHT axis. - // Since that is the only action, there is no need to check the ev.action variable. + // Check which icon was pressed and act accordingly. protected onAttributeAction(ev: AttributeActionEvent) { - if(this.widgetConfig.attributeRefs.indexOf(ev.detail.attributeRef) >= 0) { - if(this.widgetConfig.rightAxisAttributes.includes(ev.detail.attributeRef)) { - this.removeFromRightAxis(ev.detail.attributeRef); - } else { - this.addToRightAxis(ev.detail.attributeRef); - } - this.notifyConfigUpdate(); + const { asset ,attributeRef, action } = ev.detail; + + const findAttributeIndex = (array: AttributeRef[], ref: AttributeRef) => { + return array.findIndex(item => item.id === ref.id && item.name === ref.name); + }; + + switch (action.icon) { + case "palette": // Change color + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.style.border = 'none'; + colorInput.style.height = '31px'; + colorInput.style.width = '31px'; + colorInput.style.padding = '1px 3px'; + colorInput.style.minHeight = '22px'; + colorInput.style.minWidth = '30px'; + colorInput.style.cursor = 'pointer'; + colorInput.addEventListener('change', (e: any) => { + const color = e.target.value; + const existingIndex = this.widgetConfig.colorPickedAttributes.findIndex(item => + item.attributeRef.id === attributeRef.id && item.attributeRef.name === attributeRef.name + ); + if (existingIndex >= 0) { + this.widgetConfig.colorPickedAttributes[existingIndex].color = color; + } else { + this.widgetConfig.colorPickedAttributes.push({ attributeRef, color }); + } + this.notifyConfigUpdate(); + }); + colorInput.click(); + break; + case "arrow-right-bold": + case "arrow-left-bold": + this.toggleAttributeSetting("rightAxisAttributes", attributeRef); + break; + case "chart-bell-curve-cumulative": + this.toggleAttributeSetting("smoothAttributes", attributeRef); + break; + case "square-wave": + this.toggleAttributeSetting("steppedAttributes", attributeRef); + break; + case "chart-areaspline-variant": + this.toggleAttributeSetting("areaAttributes", attributeRef); + break; + case "arrange-send-backward": + this.toggleAttributeSetting("faintAttributes", attributeRef); + break; + case "arrow-expand-right": + this.toggleAttributeSetting("extendedAttributes", attributeRef); + break; + default: + console.warn('Unknown attribute panel action:', action); } + console.log("end of onAttributeAction" + JSON.stringify(this.widgetConfig.attributeSettings)); } // When the list of attributeRefs is changed by the asset selector, - // we should remove the "right axis" references for the attributes that got removed. + // we should remove the settings references for the attributes that got removed. // Also update the WidgetConfig attributeRefs field as usual protected onAttributesSelect(ev: AttributesSelectEvent) { const removedAttributeRefs = this.widgetConfig.attributeRefs.filter(ar => !ev.detail.attributeRefs.includes(ar)); - removedAttributeRefs.forEach(raf => this.removeFromRightAxis(raf)); + + removedAttributeRefs.forEach(raf => { + this.removeFromAttributeSettings(raf); + this.removeFromColorPickedAttributes(raf); + }); + this.widgetConfig.attributeRefs = ev.detail.attributeRefs; this.notifyConfigUpdate(); } - protected addToRightAxis(attributeRef: AttributeRef, notify = false) { - if(!this.widgetConfig.rightAxisAttributes.includes(attributeRef)) { - this.widgetConfig.rightAxisAttributes.push(attributeRef); - if(notify) { - this.notifyConfigUpdate(); - } - } + protected removeFromAttributeSettings(attributeRef: AttributeRef) { + const settings = this.widgetConfig.attributeSettings; + (Object.keys(settings) as (keyof typeof settings)[]).forEach(key => { + settings[key] = settings[key].filter((ar: AttributeRef) => ar.id !== attributeRef.id || ar.name !== attributeRef.name); + }); } - protected removeFromRightAxis(attributeRef: AttributeRef, notify = false) { - if(this.widgetConfig.rightAxisAttributes.includes(attributeRef)) { - this.widgetConfig.rightAxisAttributes.splice(this.widgetConfig.rightAxisAttributes.indexOf(attributeRef), 1); - if(notify) { - this.notifyConfigUpdate(); - } + protected toggleAttributeSetting( + setting: keyof ChartWidgetConfig["attributeSettings"], + attributeRef: AttributeRef, + ): void { + const attributes = this.widgetConfig.attributeSettings[setting]; + const index = attributes.findIndex( + (item: AttributeRef) => item.id === attributeRef.id && item.name === attributeRef.name + ); + if (index < 0) { + attributes.push(attributeRef); + } else { + attributes.splice(index, 1); } + this.notifyConfigUpdate(); } - protected onTimePresetSelect(ev: OrInputChangedEvent) { - this.widgetConfig.defaultTimePresetKey = ev.detail.value.toString(); + protected removeFromColorPickedAttributes(attributeRef: AttributeRef) { + this.widgetConfig.colorPickedAttributes = this.widgetConfig.colorPickedAttributes.filter( + item => item.attributeRef.id !== attributeRef.id || item.attributeRef.name !== attributeRef.name + ); + } + + protected onTimePreFixSelect(ev: OrInputChangedEvent) { + this.widgetConfig.defaultTimePrefixKey = ev.detail.value.toString(); + this.notifyConfigUpdate(); + } + + protected onTimeWindowSelect(ev: OrInputChangedEvent) { + this.widgetConfig.defaultTimeWindowKey = ev.detail.value.toString(); this.notifyConfigUpdate(); } @@ -273,6 +443,16 @@ export class ChartSettings extends WidgetSettings { this.notifyConfigUpdate(); } + protected onShowZoomBarToggle(ev: OrInputChangedEvent) { + this.widgetConfig.showZoomBar = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onShowToolBoxToggle(ev: OrInputChangedEvent) { + this.widgetConfig.showToolBox = ev.detail.value; + this.notifyConfigUpdate(); + } + protected setAxisMinMaxValue(axis: 'left' | 'right', type: 'min' | 'max', value?: number) { if(axis === 'left') { if(type === 'min') { @@ -302,4 +482,14 @@ export class ChartSettings extends WidgetSettings { this.widgetConfig.datapointQuery.type = this.samplingOptions.get(ev.detail.value)! as any; this.notifyConfigUpdate(); } + + protected onMaxConcurrentDatapointsValueChange(ev: OrInputChangedEvent) { + this.widgetConfig.maxConcurrentDatapoints = ev.detail.value; + this.notifyConfigUpdate(); + } + + protected onShowSymbolMaxDatapointsValueChange(ev: OrInputChangedEvent) { + this.widgetConfig.showSymbolMaxDatapoints = ev.detail.value; + this.notifyConfigUpdate(); + } } diff --git a/ui/component/or-dashboard-builder/src/widgets/chart-widget.ts b/ui/component/or-dashboard-builder/src/widgets/chart-widget.ts index e7c617fd9c..ff0995addb 100644 --- a/ui/component/or-dashboard-builder/src/widgets/chart-widget.ts +++ b/ui/component/or-dashboard-builder/src/widgets/chart-widget.ts @@ -1,7 +1,6 @@ import {AssetDatapointLTTBQuery, AssetDatapointQueryUnion, Attribute, AttributeRef} from "@openremote/model"; import {html, PropertyValues, TemplateResult } from "lit"; import { when } from "lit/directives/when.js"; -import {TimePresetCallback} from "@openremote/or-chart"; import moment from "moment"; import {OrAssetWidget} from "../util/or-asset-widget"; import { customElement, state } from "lit/decorators.js"; @@ -13,51 +12,73 @@ import "@openremote/or-chart"; export interface ChartWidgetConfig extends WidgetConfig { attributeRefs: AttributeRef[]; - rightAxisAttributes: AttributeRef[]; + colorPickedAttributes: Array<{ attributeRef: AttributeRef; color: string }>; + attributeSettings: { + rightAxisAttributes: AttributeRef[], + smoothAttributes: AttributeRef[], + steppedAttributes: AttributeRef[], + areaAttributes: AttributeRef[], + faintAttributes: AttributeRef[], + extendedAttributes: AttributeRef[], + }, datapointQuery: AssetDatapointQueryUnion; - chartOptions?: any; // ChartConfiguration<"line", ScatterDataPoint[]> + chartOptions?: any; showTimestampControls: boolean; - defaultTimePresetKey: string; + defaultTimeWindowKey: string; + defaultTimePrefixKey: string; showLegend: boolean; + showZoomBar: boolean; + showToolBox: boolean; + showSymbolMaxDatapoints: number; + maxConcurrentDatapoints: number; } -function getDefaultTimePresetOptions(): Map { - return new Map([ - ["lastHour", (date: Date) => [moment(date).subtract(1, 'hour').toDate(), date]], - ["last24Hours", (date: Date) => [moment(date).subtract(24, 'hours').toDate(), date]], - ["last7Days", (date: Date) => [moment(date).subtract(7, 'days').toDate(), date]], - ["last30Days", (date: Date) => [moment(date).subtract(30, 'days').toDate(), date]], - ["last90Days", (date: Date) => [moment(date).subtract(90, 'days').toDate(), date]], - ["last6Months", (date: Date) => [moment(date).subtract(6, 'months').toDate(), date]], - ["lastYear", (date: Date) => [moment(date).subtract(1, 'year').toDate(), date]], - ["thisHour", (date: Date) => [moment(date).startOf('hour').toDate(), moment(date).endOf('hour').toDate()]], - ["thisDay", (date: Date) => [moment(date).startOf('day').toDate(), moment(date).endOf('day').toDate()]], - ["thisWeek", (date: Date) => [moment(date).startOf('isoWeek').toDate(), moment(date).endOf('isoWeek').toDate()]], - ["thisMonth", (date: Date) => [moment(date).startOf('month').toDate(), moment(date).endOf('month').toDate()]], - ["thisYear", (date: Date) => [moment(date).startOf('year').toDate(), moment(date).endOf('year').toDate()]], - ["yesterday", (date: Date) => [moment(date).subtract(24, 'hours').startOf('day').toDate(), moment(date).subtract(24, 'hours').endOf('day').toDate()]], - ["thisDayLastWeek", (date: Date) => [moment(date).subtract(1, 'week').startOf('day').toDate(), moment(date).subtract(1, 'week').endOf('day').toDate()]], - ["previousWeek", (date: Date) => [moment(date).subtract(1, 'week').startOf('isoWeek').toDate(), moment(date).subtract(1, 'week').endOf('isoWeek').toDate()]], - ["previousMonth", (date: Date) => [moment(date).subtract(1, 'month').startOf('month').toDate(), moment(date).subtract(1, 'month').endOf('month').toDate()]], - ["previousYear", (date: Date) => [moment(date).subtract(1, 'year').startOf('year').toDate(), moment(date).subtract(1, 'year').endOf('year').toDate()]] +function getDefaultTimeWindowOptions(): Map { + return new Map([ + ["5Minutes", ['minutes', 5]], + ["20Minutes", ['minutes', 20]], + ["60Minutes", ['minutes', 60]], + ["hour", ['hours', 1]], + ["6Hours", ['hours', 6]], + ["24Hours", ['hours', 24]], + ["day", ['days', 1]], + ["7Days", ['days', 7]], + ["week", ['weeks', 1]], + ["30Days", ['days', 30]], + ["month", ['months', 1]], + ["365Days", ['days', 365]], + ["year", ['years', 1]] ]); } +function getDefaultTimePreFixOptions(): string[] { + return ["this", "last"]; +} + function getDefaultSamplingOptions(): Map { return new Map([["lttb", 'lttb'], ["withInterval", 'interval']]); } function getDefaultWidgetConfig(): ChartWidgetConfig { - const preset = "last24Hours" - const dateFunc = getDefaultTimePresetOptions().get(preset) as TimePresetCallback; - const dates = dateFunc(new Date()); + const preset = "24Hours"; // Default time preset, "last" prefix is hardcoded in startDate and endDate below. + const dateFunc = getDefaultTimeWindowOptions().get(preset); + const startDate = moment().subtract(dateFunc![1], dateFunc![0]).startOf(dateFunc![0]); + const endDate = dateFunc![1]== 1 ? moment().endOf(dateFunc![0]) : moment(); return { attributeRefs: [], - rightAxisAttributes: [], + colorPickedAttributes: [], + attributeSettings: { + rightAxisAttributes: [], + smoothAttributes: [], + steppedAttributes: [], + areaAttributes: [], + faintAttributes: [], + extendedAttributes: [], + }, datapointQuery: { type: "lttb", - fromTimestamp: dates[0].getTime(), - toTimestamp: dates[1].getTime() + fromTimestamp: startDate.toDate().getTime(), + toTimestamp: endDate.toDate().getTime(), }, chartOptions: { options: { @@ -74,8 +95,14 @@ function getDefaultWidgetConfig(): ChartWidgetConfig { }, }, showTimestampControls: false, - defaultTimePresetKey: preset, - showLegend: true + defaultTimeWindowKey: preset, + defaultTimePrefixKey: "last", + showLegend: true, + showZoomBar: false, + showToolBox: false, + showSymbolMaxDatapoints: 30, + maxConcurrentDatapoints: 100 + }; } @@ -104,7 +131,8 @@ export class ChartWidget extends OrAssetWidget { }, getSettingsHtml(config: ChartWidgetConfig): WidgetSettings { const settings = new ChartSettings(config); - settings.setTimePresetOptions(getDefaultTimePresetOptions()); + settings.setTimeWindowOptions(getDefaultTimeWindowOptions()); + settings.setTimePrefixOptions(getDefaultTimePreFixOptions()); settings.setSamplingOptions(getDefaultSamplingOptions()); return settings; }, @@ -192,11 +220,20 @@ export class ChartWidget extends OrAssetWidget { `, () => { return html` - `;