diff --git a/src/components/chart/down-sample.ts b/src/components/chart/down-sample.ts new file mode 100644 index 0000000000..9790de2a26 --- /dev/null +++ b/src/components/chart/down-sample.ts @@ -0,0 +1,72 @@ +import type { LineSeriesOption } from "echarts"; + +export function downSampleLineData( + data: LineSeriesOption["data"], + chartWidth: number, + minX?: number, + maxX?: number +) { + if (!data || data.length < 10) { + return data; + } + const width = chartWidth * window.devicePixelRatio; + if (data.length <= width) { + 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 frames = new Map< + number, + { + min: { point: (typeof data)[number]; x: number; y: number }; + max: { point: (typeof data)[number]; x: number; y: number }; + } + >(); + + // Group points into frames + for (const point of data) { + const pointData = getPointData(point); + if (!Array.isArray(pointData)) continue; + const x = Number(pointData[0]); + const y = Number(pointData[1]); + if (isNaN(x) || isNaN(y)) continue; + + const frameIndex = Math.floor((x - min) / step); + const frame = frames.get(frameIndex); + if (!frame) { + frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } }); + } else { + if (frame.min.y > y) { + frame.min = { point, x, y }; + } + if (frame.max.y < y) { + frame.max = { point, x, y }; + } + } + } + + // Convert frames back to points + const result: typeof data = []; + 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 + if (frame.min.x > frame.max.x) { + result.push(frame.max.point); + } + result.push(frame.min.point); + if (frame.min.x < frame.max.x) { + result.push(frame.max.point); + } + } + + return result; +} + +function getPointData(point: NonNullable[number]) { + const pointData = + point && typeof point === "object" && "value" in point + ? point.value + : point; + return pointData as number[]; +} diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 597b5650fc..64db12b3df 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -27,6 +27,7 @@ import "../ha-icon-button"; import { formatTimeLabel } from "./axis-label"; import { ensureArray } from "../../common/array/ensure-array"; import "../chips/ha-assist-chip"; +import { downSampleLineData } from "./down-sample"; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; const LEGEND_OVERFLOW_LIMIT = 10; @@ -613,19 +614,21 @@ export class HaChartBase extends LitElement { } private _getSeries() { - const series = ensureArray(this.data).filter( - (d) => !this._hiddenDatasets.has(String(d.name ?? d.id)) - ); + const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as + | XAXisOption + | undefined; const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as | YAXisOption | undefined; - if (yAxis?.type === "log") { - // set <=0 values to null so they render as gaps on a log graph - return series.map((d) => - d.type === "line" - ? { - ...d, - data: d.data?.map((v) => + const series = ensureArray(this.data) + .filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id))) + .map((s) => { + if (s.type === "line") { + if (yAxis?.type === "log") { + // set <=0 values to null so they render as gaps on a log graph + return { + ...s, + data: s.data?.map((v) => Array.isArray(v) ? [ v[0], @@ -634,10 +637,26 @@ export class HaChartBase extends LitElement { ] : v ), - } - : d - ); - } + }; + } + if (s.sampling === "minmax") { + const minX = + xAxis?.min && typeof xAxis.min === "number" + ? xAxis.min + : undefined; + const maxX = + xAxis?.max && typeof xAxis.max === "number" + ? xAxis.max + : undefined; + return { + ...s, + sampling: undefined, + data: downSampleLineData(s.data, this.clientWidth, minX, maxX), + }; + } + } + return s; + }); return series; }