From 29bc894dbd6c3bf3cd9bb268e02a6499ab3f9c66 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 13 Oct 2025 16:36:33 +0300 Subject: [PATCH] Improve sampling in trend feature and sensor card (#27190) --- src/components/chart/down-sample.ts | 21 +- src/components/chart/ha-chart-base.ts | 2 +- .../hui-trend-graph-card-feature.ts | 20 +- .../lovelace/common/graph/coordinates.ts | 179 +++++++----------- .../lovelace/components/hui-graph-base.ts | 14 +- .../header-footer/hui-graph-header-footer.ts | 22 ++- 6 files changed, 114 insertions(+), 144 deletions(-) diff --git a/src/components/chart/down-sample.ts b/src/components/chart/down-sample.ts index 9790de2a26..47855592c1 100644 --- a/src/components/chart/down-sample.ts +++ b/src/components/chart/down-sample.ts @@ -1,21 +1,22 @@ import type { LineSeriesOption } from "echarts"; -export function downSampleLineData( - data: LineSeriesOption["data"], - chartWidth: number, +export function downSampleLineData< + T extends [number, number] | NonNullable[number], +>( + data: T[] | undefined, + maxDetails: number, minX?: number, maxX?: number -) { - if (!data || data.length < 10) { - return data; +): T[] { + if (!data) { + return []; } - const width = chartWidth * window.devicePixelRatio; - if (data.length <= width) { + if (data.length <= maxDetails) { return data; } const min = minX ?? getPointData(data[0]!)[0]; const max = maxX ?? getPointData(data[data.length - 1]!)[0]; - const step = Math.floor((max - min) / width); + const step = Math.ceil((max - min) / Math.floor(maxDetails)); const frames = new Map< number, { @@ -47,7 +48,7 @@ export function downSampleLineData( } // Convert frames back to points - const result: typeof data = []; + const result: T[] = []; for (const [_i, frame] of frames) { // Use min/max points to preserve visual accuracy // The order of the data must be preserved so max may be before min diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 97ff66dc02..e13ca3cfc6 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -805,7 +805,7 @@ export class HaChartBase extends LitElement { sampling: undefined, data: downSampleLineData( data as LineSeriesOption["data"], - this.clientWidth, + this.clientWidth * window.devicePixelRatio, minX, maxX ), diff --git a/src/panels/lovelace/card-features/hui-trend-graph-card-feature.ts b/src/panels/lovelace/card-features/hui-trend-graph-card-feature.ts index 273447beb7..dfe3596c4b 100644 --- a/src/panels/lovelace/card-features/hui-trend-graph-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-trend-graph-card-feature.ts @@ -43,6 +43,8 @@ class HuiHistoryChartCardFeature @state() private _coordinates?: [number, number][]; + @state() private _yAxisOrigin?: number; + private _interval?: number; static getStubConfig(): TrendGraphCardFeatureConfig { @@ -105,7 +107,10 @@ class HuiHistoryChartCardFeature `; } return html` - + `; } @@ -123,14 +128,15 @@ class HuiHistoryChartCardFeature return subscribeHistoryStatesTimeWindow( this.hass!, (historyStates) => { - this._coordinates = + const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState( historyStates[this.context!.entity_id!], - hourToShow, - 500, - 2, - undefined - ) || []; + this.clientWidth, + this.clientHeight, + this.clientWidth / 5 // sample to 1 point per 5 pixels + ); + this._coordinates = points; + this._yAxisOrigin = yAxisOrigin; }, hourToShow, [this.context!.entity_id!] diff --git a/src/panels/lovelace/common/graph/coordinates.ts b/src/panels/lovelace/common/graph/coordinates.ts index d7d2eea4c1..533bdd706b 100644 --- a/src/panels/lovelace/common/graph/coordinates.ts +++ b/src/panels/lovelace/common/graph/coordinates.ts @@ -1,134 +1,85 @@ -import { strokeWidth } from "../../../../data/graph"; +import { downSampleLineData } from "../../../../components/chart/down-sample"; import type { EntityHistoryState } from "../../../../data/history"; -const average = (items: any[]): number => - items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) / items.length; - -const lastValue = (items: any[]): number => - parseFloat(items[items.length - 1].state) || 0; - const calcPoints = ( - history: any, - hours: number, + history: [number, number][], width: number, - detail: number, - min: number, - max: number -): [number, number][] => { - const coords = [] as [number, number][]; - const height = 80; - let yRatio = (max - min) / height; - yRatio = yRatio !== 0 ? yRatio : height; - let xRatio = width / (hours - (detail === 1 ? 1 : 0)); - xRatio = isFinite(xRatio) ? xRatio : width; - - let first = history.filter(Boolean)[0]; - if (detail > 1) { - first = first.filter(Boolean)[0]; - } - let last = [average(first), lastValue(first)]; - - const getY = (value: number): number => - height + strokeWidth / 2 - (value - min) / yRatio; - - const getCoords = (item: any[], i: number, offset = 0, depth = 1) => { - if (depth > 1 && item) { - return item.forEach((subItem, index) => - getCoords(subItem, i, index, depth - 1) - ); + height: number, + limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number } +) => { + let yAxisOrigin = height; + let minY = limits?.minY ?? history[0][1]; + let maxY = limits?.maxY ?? history[0][1]; + const minX = limits?.minX ?? history[0][0]; + const maxX = limits?.maxX ?? history[history.length - 1][0]; + history.forEach(([_, stateValue]) => { + if (stateValue < minY) { + minY = stateValue; + } else if (stateValue > maxY) { + maxY = stateValue; } - - const x = xRatio * (i + offset / 6); - - if (item) { - last = [average(item), lastValue(item)]; - } - const y = getY(item ? last[0] : last[1]); - return coords.push([x, y]); - }; - - for (let i = 0; i < history.length; i += 1) { - getCoords(history[i], i, 0, detail); + }); + const rangeY = maxY - minY || minY * 0.1; + if (maxY < 0) { + // all values are negative + // add margin + maxY += rangeY * 0.1; + maxY = Math.min(0, maxY); + yAxisOrigin = 0; + } else if (minY < 0) { + // some values are negative + yAxisOrigin = (maxY / (maxY - minY || 1)) * height; + } else { + // all values are positive + // add margin + minY -= rangeY * 0.1; + minY = Math.max(0, minY); } - - coords.push([width, getY(last[1])]); - return coords; + const yDenom = maxY - minY || 1; + const xDenom = maxX - minX || 1; + const points: [number, number][] = history.map((point) => { + const x = ((point[0] - minX) / xDenom) * width; + const y = height - ((point[1] - minY) / yDenom) * height; + return [x, y]; + }); + points.push([width, points[points.length - 1][1]]); + return { points, yAxisOrigin }; }; export const coordinates = ( - history: any, - hours: number, + history: [number, number][], width: number, - detail: number, - limits?: { min?: number; max?: number } -): [number, number][] | undefined => { - history.forEach((item) => { - item.state = Number(item.state); - }); - history = history.filter((item) => !Number.isNaN(item.state)); + height: number, + maxDetails: number, + limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number } +) => { + history = history.filter((item) => !Number.isNaN(item[1])); - const min = - limits?.min !== undefined - ? limits.min - : Math.min(...history.map((item) => item.state)); - const max = - limits?.max !== undefined - ? limits.max - : Math.max(...history.map((item) => item.state)); - const now = new Date().getTime(); - - const reduce = (res, item, point) => { - const age = now - new Date(item.last_changed).getTime(); - - let key = Math.abs(age / (1000 * 3600) - hours); - if (point) { - key = (key - Math.floor(key)) * 60; - key = Number((Math.round(key / 10) * 10).toString()[0]); - } else { - key = Math.floor(key); - } - if (!res[key]) { - res[key] = []; - } - res[key].push(item); - return res; - }; - - history = history.reduce((res, item) => reduce(res, item, false), []); - if (detail > 1) { - history = history.map((entry) => - entry.reduce((res, item) => reduce(res, item, true), []) - ); - } - - if (!history.length) { - return undefined; - } - - return calcPoints(history, hours, width, detail, min, max); + const sampledData: [number, number][] = downSampleLineData( + history, + maxDetails, + limits?.minX, + limits?.maxX + ); + return calcPoints(sampledData, width, height, limits); }; -interface NumericEntityHistoryState { - state: number; - last_changed: number; -} - export const coordinatesMinimalResponseCompressedState = ( - history: EntityHistoryState[], - hours: number, + history: EntityHistoryState[] | undefined, width: number, - detail: number, - limits?: { min?: number; max?: number } -): [number, number][] | undefined => { - if (!history) { - return undefined; + height: number, + maxDetails: number, + limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number } +) => { + if (!history?.length) { + return { points: [], yAxisOrigin: 0 }; } - const numericHistory: NumericEntityHistoryState[] = history.map((item) => ({ - state: Number(item.s), + const mappedHistory: [number, number][] = history.map((item) => [ // With minimal response and compressed state, we don't have last_changed, // so we use last_updated since its always the same as last_changed since // we already filtered out states that are the same. - last_changed: item.lu * 1000, - })); - return coordinates(numericHistory, hours, width, detail, limits); + item.lu * 1000, + Number(item.s), + ]); + return coordinates(mappedHistory, width, height, maxDetails, limits); }; diff --git a/src/panels/lovelace/components/hui-graph-base.ts b/src/panels/lovelace/components/hui-graph-base.ts index 0c9214fb8c..089a6410d3 100644 --- a/src/panels/lovelace/components/hui-graph-base.ts +++ b/src/panels/lovelace/components/hui-graph-base.ts @@ -6,20 +6,26 @@ import { getPath } from "../common/graph/get-path"; @customElement("hui-graph-base") export class HuiGraphBase extends LitElement { - @property() public coordinates?: any; + @property({ attribute: false }) public coordinates?: number[][]; + + @property({ attribute: "y-axis-origin", type: Number }) + public yAxisOrigin?: number; @state() private _path?: string; protected render(): TemplateResult { + const width = this.clientWidth || 500; + const height = this.clientHeight || width / 5; + const yAxisOrigin = this.yAxisOrigin ?? height; return html` ${this._path - ? svg` + ? svg` @@ -38,7 +44,7 @@ export class HuiGraphBase extends LitElement { ` - : svg``} + : svg``} `; } diff --git a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts index 842e94af82..b45ba3c931 100644 --- a/src/panels/lovelace/header-footer/hui-graph-header-footer.ts +++ b/src/panels/lovelace/header-footer/hui-graph-header-footer.ts @@ -153,14 +153,20 @@ export class HuiGraphHeaderFooter // Message came in before we had a chance to unload return; } - this._coordinates = - coordinatesMinimalResponseCompressedState( - combinedHistory[this._config.entity], - this._config.hours_to_show!, - 500, - this._config.detail!, - this._config.limits - ) || []; + const width = this.clientWidth || this.offsetWidth; + // sample to 1 point per hour or 1 point per 5 pixels + const maxDetails = + this._config.detail! > 1 + ? Math.max(width / 5, this._config.hours_to_show!) + : this._config.hours_to_show!; + const { points } = coordinatesMinimalResponseCompressedState( + combinedHistory[this._config.entity], + width, + width / 5, + maxDetails, + { minY: this._config.limits?.min, maxY: this._config.limits?.max } + ); + this._coordinates = points; }, this._config.hours_to_show!, [this._config.entity]