1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

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
This commit is contained in:
Petar Petrov
2026-03-18 11:58:51 +01:00
committed by GitHub
parent f2f1044992
commit 3ac2434b6f
4 changed files with 214 additions and 1 deletions

View File

@@ -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,

View File

@@ -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);
}
}
};

View File

@@ -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,

View File

@@ -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]);
});
});