From 3ac2434b6f6ab1c9da628c436574255afcb85365 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 18 Mar 2026 11:58:51 +0100 Subject: [PATCH] Rescale Y-axis on chart zoom via custom AxisProxy filterMode (#30192) * Rescale Y-axis on chart zoom via custom AxisProxy filterMode Patch ECharts' AxisProxy.filterData to support a "boundaryFilter" mode that keeps the nearest data point outside each zoom boundary while filtering distant points. This lets ECharts natively rescale the Y-axis to the visible data range without causing line gaps at the zoom edges. * Add tests for ECharts AxisProxy patch internals Verify that the ECharts internals our boundaryFilter patch relies on still exist (filterData, getTargetSeriesModels on AxisProxy prototype), and test the patch behavior: delegation for other filterModes, early return for non-matching models, and correct boundary-preserving filtering. * Update comment --- src/components/chart/ha-chart-base.ts | 5 +- src/resources/echarts/axis-proxy-patch.ts | 67 +++++++++ src/resources/echarts/echarts.ts | 2 + .../echarts/axis-proxy-patch.test.ts | 141 ++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/resources/echarts/axis-proxy-patch.ts create mode 100644 test/resources/echarts/axis-proxy-patch.test.ts diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 19f1d1bbe3..3ec3b2108a 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -578,7 +578,10 @@ export class HaChartBase extends LitElement { id: "dataZoom", type: "inside", orient: "horizontal", - filterMode: "none", + // "boundaryFilter" is a custom mode added via axis-proxy-patch.ts. + // It rescales the Y-axis to the visible data while keeping one point + // just outside each boundary to avoid line gaps at the zoom edges. + filterMode: "boundaryFilter" as any, xAxisIndex: 0, moveOnMouseMove: !this._isTouchDevice || this._isZoomed, preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed, diff --git a/src/resources/echarts/axis-proxy-patch.ts b/src/resources/echarts/axis-proxy-patch.ts new file mode 100644 index 0000000000..0076b589c6 --- /dev/null +++ b/src/resources/echarts/axis-proxy-patch.ts @@ -0,0 +1,67 @@ +// Patch ECharts AxisProxy to support a "boundaryFilter" filterMode. +// +// ECharts' built-in filterMode "filter" rescales the Y-axis when zooming but +// clips data at the zoom boundaries, causing line gaps at the edges. +// filterMode "none" avoids the gaps but never rescales the Y-axis. +// +// "boundaryFilter" (our custom mode) keeps the nearest data point just outside +// each zoom boundary—so lines draw to the edge without gaps—while still +// filtering out the distant points so ECharts rescales the Y-axis naturally. +// +// The patch is applied once at module load time, before any chart is created. +// Unknown filterMode values pass through to the original implementation, so +// there is no impact on other chart instances. + +import AxisProxy from "echarts/lib/component/dataZoom/AxisProxy"; + +const origFilterData = (AxisProxy as any).prototype.filterData; +(AxisProxy as any).prototype.filterData = function ( + dataZoomModel: any, + api: any +): void { + if (dataZoomModel.get("filterMode") !== "boundaryFilter") { + origFilterData.call(this, dataZoomModel, api); + return; + } + if (dataZoomModel !== this._dataZoomModel) { + return; + } + const axisDim = this._dimName; + const valueWindow = this._valueWindow; + const seriesModels = this.getTargetSeriesModels(); + for (const seriesModel of seriesModels) { + const seriesData = seriesModel.getData(); + const dataDims = seriesData.mapDimensionsAll(axisDim); + if (!dataDims.length) { + continue; + } + const store = seriesData.getStore(); + const dimIndex = seriesData.getDimensionIndex(dataDims[0]); + const count = seriesData.count(); + // Phase 1: find the indices of the nearest points just outside each boundary + let leftBoundaryIdx = -1; + let rightBoundaryIdx = -1; + for (let i = 0; i < count; i++) { + const v = store.get(dimIndex, i); + if (isNaN(v)) { + continue; + } + if (v < valueWindow[0]) { + leftBoundaryIdx = i; + } else if (v > valueWindow[1] && rightBoundaryIdx === -1) { + rightBoundaryIdx = i; + } + } + // Phase 2: keep in-window points and the two boundary anchor points + seriesData.filterSelf((dataIndex: number) => { + if (dataIndex === leftBoundaryIdx || dataIndex === rightBoundaryIdx) { + return true; + } + const v = store.get(dimIndex, dataIndex); + return !isNaN(v) && v >= valueWindow[0] && v <= valueWindow[1]; + }); + for (const dim of dataDims) { + seriesData.setApproximateExtent(valueWindow, dim); + } + } +}; diff --git a/src/resources/echarts/echarts.ts b/src/resources/echarts/echarts.ts index 114fe9212d..9a8d467fae 100644 --- a/src/resources/echarts/echarts.ts +++ b/src/resources/echarts/echarts.ts @@ -29,6 +29,8 @@ import { CanvasRenderer } from "echarts/renderers"; // eslint-disable-next-line import/no-extraneous-dependencies import LinearGradient from "zrender/lib/graphic/LinearGradient"; +import "./axis-proxy-patch"; + import type { // The series option types are defined with the SeriesOption suffix BarSeriesOption, diff --git a/test/resources/echarts/axis-proxy-patch.test.ts b/test/resources/echarts/axis-proxy-patch.test.ts new file mode 100644 index 0000000000..5c2f36a86d --- /dev/null +++ b/test/resources/echarts/axis-proxy-patch.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import AxisProxy from "echarts/lib/component/dataZoom/AxisProxy"; + +/** + * These tests verify that the ECharts internals our axis-proxy-patch relies on + * still exist. If an ECharts upgrade changes these, the tests will fail, + * alerting us that the patch in src/resources/echarts/axis-proxy-patch.ts + * needs to be updated. + */ +describe("ECharts internals required by axis-proxy-patch", () => { + it("AxisProxy has a filterData method on its prototype", () => { + expect(typeof (AxisProxy as any).prototype.filterData).toBe("function"); + }); + + it("AxisProxy prototype exposes the expected instance fields pattern", () => { + // The patch accesses these properties via `this` inside filterData: + // this._dataZoomModel, this._dimName, this._valueWindow, + // this.getTargetSeriesModels() + // We can't easily construct a real AxisProxy, but we can verify + // getTargetSeriesModels exists on the prototype. + expect(typeof (AxisProxy as any).prototype.getTargetSeriesModels).toBe( + "function" + ); + }); +}); + +describe("axis-proxy-patch applies boundaryFilter mode", () => { + it("patches filterData to handle boundaryFilter", async () => { + // Import the patch (side-effect module) + await import("../../../src/resources/echarts/axis-proxy-patch"); + + const filterData = (AxisProxy as any).prototype.filterData; + + // Create a mock dataZoomModel that requests boundaryFilter + const mockDataZoomModel = { + get: (key: string) => + key === "filterMode" ? "boundaryFilter" : undefined, + }; + + // The patched filterData should not throw when called with + // boundaryFilter and a non-matching _dataZoomModel (early return path) + const mockProxy = { + _dataZoomModel: "different-model", + }; + + // Should return early because dataZoomModel !== this._dataZoomModel + expect(() => + filterData.call(mockProxy, mockDataZoomModel, {}) + ).not.toThrow(); + }); + + it("falls through to original filterData for other filterModes", async () => { + await import("../../../src/resources/echarts/axis-proxy-patch"); + + const filterData = (AxisProxy as any).prototype.filterData; + + // Temporarily replace the original to verify delegation + const calls: any[] = []; + + // The patched function stores the original in its closure. + // We can verify it doesn't enter the boundaryFilter path by checking + // that no boundary-specific logic runs (no getTargetSeriesModels call). + const mockDataZoomModel = { + get: (key: string) => (key === "filterMode" ? "filter" : undefined), + }; + + const mockProxy = { + getTargetSeriesModels: () => { + calls.push("getTargetSeriesModels"); + return []; + }, + }; + + // Should not throw — the original filterData handles non-matching gracefully + expect(() => + filterData.call(mockProxy, mockDataZoomModel, {}) + ).not.toThrow(); + + // getTargetSeriesModels should NOT have been called because the + // patched function delegates to the original for filterMode !== "boundaryFilter" + expect(calls).toEqual([]); + }); + + it("filters data keeping boundary points", async () => { + await import("../../../src/resources/echarts/axis-proxy-patch"); + + const filterData = (AxisProxy as any).prototype.filterData; + + // Simulate data: timestamps [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + // Zoom window: [3, 7] + // Expected: keep index 1 (value 2, left boundary), indices 2-6 (in window), + // index 7 (value 8, right boundary), filter out 0, 8, 9 + const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const kept: number[] = []; + + const mockStore = { + get: (_dimIndex: number, i: number) => values[i], + }; + + const mockSeriesData = { + mapDimensionsAll: () => ["x"], + getStore: () => mockStore, + getDimensionIndex: () => 0, + count: () => values.length, + filterSelf: (fn: (idx: number) => boolean) => { + for (let i = 0; i < values.length; i++) { + if (fn(i)) { + kept.push(i); + } + } + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setApproximateExtent: () => {}, + }; + + const mockSeriesModel = { + getData: () => mockSeriesData, + }; + + const mockDataZoomModel = { + get: (key: string) => + key === "filterMode" ? "boundaryFilter" : undefined, + }; + + const mockProxy = { + _dataZoomModel: mockDataZoomModel, + _dimName: "x", + _valueWindow: [3, 7], + getTargetSeriesModels: () => [mockSeriesModel], + }; + + filterData.call(mockProxy, mockDataZoomModel, {}); + + // Index 0 (value 1): filtered out + // Index 1 (value 2): left boundary (nearest < 3) + // Index 2-6 (values 3-7): in window + // Index 7 (value 8): right boundary (nearest > 7) + // Index 8-9 (values 9-10): filtered out + expect(kept).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); +});