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:
@@ -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,
|
||||
|
||||
67
src/resources/echarts/axis-proxy-patch.ts
Normal file
67
src/resources/echarts/axis-proxy-patch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
141
test/resources/echarts/axis-proxy-patch.test.ts
Normal file
141
test/resources/echarts/axis-proxy-patch.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user