From 0e8e054db1c50ba71d99a13b685e08fa01f1aa4d Mon Sep 17 00:00:00 2001
From: Jan Layola <49994364+JanLayola@users.noreply.github.com>
Date: Wed, 17 Sep 2025 09:36:28 +0200
Subject: [PATCH] Sync charts zoom in history tab (#26898)
* Add chart zoom event system and sync infrastructure to chart-base
- Replace inline datazoom handler with dedicated _handleDataZoomEvent method
- Add _syncZoomState method for zoom state synchronization
- Refactor zoom detection to be more robust and reliable
* Add hide reset button functionality
- Add hideResetButton property to ha-chart-base component
- Add hideResetButton property to state-history-chart-line component
- Add hideResetButton property to state-history-chart-timeline component
- Implement conditional reset button rendering based on hideResetButton flag
- Pass hideResetButton prop through component hierarchy
This allows parent components to control reset button visibility when
implementing custom reset functionality or coordinated multi-chart resets.
* Implement chart zoom synchronization
- Add chart-zoom event handlers to state-history-chart-line component
- Add chart-zoom event handlers to state-history-chart-timeline component
- Forward zoom events with chart index for identification
This enables individual charts to communicate zoom changes to parent
components for coordinated multi-chart synchronization.
* Add floating reset button and sync orchestration
- Add chart-zoom event type definition to HASSDomEvents interface
- Add global zoom state tracking with _hasZoomedCharts property
- Add _isSyncing flag to prevent infinite sync loops
- Implement _handleTimelineSync method for coordinating chart synchronization
- Implement _handleGlobalZoomReset method for resetting all charts
- Enable hide-reset-button on individual charts to use global reset
- Add floating reset button with Material Design styling
On history page the floating reset button appears when any chart is zoomed
and provides a single point to reset all synchronized charts simultaneously.
* Refactor chart zoom synchronization to use public API
Replace direct ECharts dispatchAction calls with proper zoom methods. The parent component now calls chartComponent.zoom() instead of accessing internal chart.dispatchAction() directly.
* Remove duplicate TypeScript declaration of the "chart-zoom" event
* Fix tooltips not shown due to xAxisPointer hidden
* Use chart zoom function in history charts
* Apply code review feedback
* Remove unnecessary any types
* Apply code review feedback
---
src/components/chart/ha-chart-base.ts | 71 ++++++++--
.../chart/state-history-chart-line.ts | 18 +++
.../chart/state-history-chart-timeline.ts | 18 +++
src/components/chart/state-history-charts.ts | 126 ++++++++++++++++--
4 files changed, 209 insertions(+), 24 deletions(-)
diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts
index 9434e1a84c..80036a1bf8 100644
--- a/src/components/chart/ha-chart-base.ts
+++ b/src/components/chart/ha-chart-base.ts
@@ -63,6 +63,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
+ @property({ attribute: "hide-reset-button", type: Boolean })
+ public hideResetButton?: boolean;
+
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -215,7 +218,7 @@ export class HaChartBase extends LitElement {
${this._renderLegend()}
- ${this._isZoomed
+ ${this._isZoomed && !this.hideResetButton
? html`
{
- const { start, end } = e.batch?.[0] ?? e;
- this._isZoomed = start !== 0 || end !== 100;
- this._zoomRatio = (end - start) / 100;
- if (this._isTouchDevice) {
- // zooming changes the axis pointer so we need to hide it
- this.chart?.dispatchAction({
- type: "hideTip",
- from: "datazoom",
- });
- }
+ this._handleDataZoomEvent(e);
});
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
+
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
@@ -868,10 +863,60 @@ export class HaChartBase extends LitElement {
});
};
+ public zoom(start: number, end: number, silent = false) {
+ this.chart?.dispatchAction({
+ type: "dataZoom",
+ start,
+ end,
+ silent,
+ });
+ }
+
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
+ private _handleDataZoomEvent(e: any) {
+ const zoomData = e.batch?.[0] ?? e;
+ let start = typeof zoomData.start === "number" ? zoomData.start : 0;
+ let end = typeof zoomData.end === "number" ? zoomData.end : 100;
+
+ if (
+ start === 0 &&
+ end === 100 &&
+ zoomData.startValue !== undefined &&
+ zoomData.endValue !== undefined
+ ) {
+ const option = this.chart!.getOption();
+ const xAxis = option.xAxis?.[0] ?? option.xAxis;
+
+ if (xAxis?.min && xAxis?.max) {
+ const axisMin = new Date(xAxis.min).getTime();
+ const axisMax = new Date(xAxis.max).getTime();
+ const axisRange = axisMax - axisMin;
+
+ start = Math.max(
+ 0,
+ Math.min(100, ((zoomData.startValue - axisMin) / axisRange) * 100)
+ );
+ end = Math.max(
+ 0,
+ Math.min(100, ((zoomData.endValue - axisMin) / axisRange) * 100)
+ );
+ }
+ }
+
+ this._isZoomed = start !== 0 || end !== 100;
+ this._zoomRatio = (end - start) / 100;
+ if (this._isTouchDevice) {
+ this.chart?.dispatchAction({
+ type: "hideTip",
+ from: "datazoom",
+ });
+ }
+ fireEvent(this, "chart-zoom", { start, end });
+ }
+
private _legendClick(ev: any) {
if (!this.chart) {
return;
@@ -1024,5 +1069,9 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
+ "chart-zoom": {
+ start: number;
+ end: number;
+ };
}
}
diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts
index e95d67ef07..1c7ad4a147 100644
--- a/src/components/chart/state-history-chart-line.ts
+++ b/src/components/chart/state-history-chart-line.ts
@@ -66,6 +66,9 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
+ @property({ attribute: "hide-reset-button", type: Boolean })
+ public hideResetButton?: boolean;
+
@state() private _chartData: LineSeriesOption[] = [];
@state() private _entityIds: string[] = [];
@@ -94,7 +97,9 @@ export class StateHistoryChartLine extends LitElement {
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
+ @chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
+ .hideResetButton=${this.hideResetButton}
>
`;
}
@@ -192,6 +197,19 @@ export class StateHistoryChartLine extends LitElement {
this._hiddenStats.delete(ev.detail.id);
}
+ public zoom(start: number, end: number) {
+ const chartBase = this.shadowRoot!.querySelector("ha-chart-base")!;
+ chartBase.zoom(start, end, true);
+ }
+
+ private _handleDataZoom(ev: CustomEvent) {
+ fireEvent(this, "chart-zoom-with-index", {
+ start: ev.detail.start ?? 0,
+ end: ev.detail.end ?? 100,
+ chartIndex: this.chartIndex,
+ });
+ }
+
public willUpdate(changedProps: PropertyValues) {
if (
changedProps.has("data") ||
diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts
index 9a9d01e95a..8969ec1cf6 100644
--- a/src/components/chart/state-history-chart-timeline.ts
+++ b/src/components/chart/state-history-chart-timeline.ts
@@ -51,6 +51,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false, type: Number }) public chartIndex?;
+ @property({ attribute: "hide-reset-button", type: Boolean })
+ public hideResetButton?: boolean;
+
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ECOption;
@@ -68,6 +71,8 @@ export class StateHistoryChartTimeline extends LitElement {
.data=${this._chartData as ECOption["series"]}
small-controls
@chart-click=${this._handleChartClick}
+ @chart-zoom=${this._handleDataZoom}
+ .hideResetButton=${this.hideResetButton}
>
`;
}
@@ -256,6 +261,19 @@ export class StateHistoryChartTimeline extends LitElement {
};
}
+ public zoom(start: number, end: number) {
+ const chartBase = this.shadowRoot!.querySelector("ha-chart-base")!;
+ chartBase.zoom(start, end, true);
+ }
+
+ private _handleDataZoom(ev: CustomEvent) {
+ fireEvent(this, "chart-zoom-with-index", {
+ start: ev.detail.start ?? 0,
+ end: ev.detail.end ?? 100,
+ chartIndex: this.chartIndex,
+ });
+ }
+
private _generateData() {
const computedStyles = getComputedStyle(this);
let stateHistory = this.data;
diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts
index 7e89aad514..f2938301b6 100644
--- a/src/components/chart/state-history-charts.ts
+++ b/src/components/chart/state-history-charts.ts
@@ -1,7 +1,8 @@
import type { PropertyValues } from "lit";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
+import { mdiRestart } from "@mdi/js";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -11,6 +12,10 @@ import type {
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
+import type { StateHistoryChartLine } from "./state-history-chart-line";
+import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
+import "../ha-fab";
+import "../ha-svg-icon";
import "./state-history-chart-line";
import "./state-history-chart-timeline";
@@ -29,6 +34,11 @@ const chunkData = (inputArray: any[], chunks: number) =>
declare global {
interface HASSDomEvents {
"y-width-changed": { value: number; chartIndex: number };
+ "chart-zoom-with-index": {
+ start: number;
+ end: number;
+ chartIndex: number;
+ };
}
}
@@ -84,6 +94,10 @@ export class StateHistoryCharts extends LitElement {
@state() private _chartCount = 0;
+ @state() private _hasZoomedCharts = false;
+
+ private _isSyncing = false;
+
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@@ -115,19 +129,36 @@ export class StateHistoryCharts extends LitElement {
// eslint-disable-next-line lit/no-this-assign-in-render
this._chartCount = combinedItems.length;
- return this.virtualize
- ? html`
-
-
-
`
- : html`${combinedItems.map((item, index) =>
- this._renderHistoryItem(item, index)
- )}`;
+
+
+ `
+ : html`${combinedItems.map((item, index) =>
+ this._renderHistoryItem(item, index)
+ )}`}
+ ${this._hasZoomedCharts
+ ? html`
+
+ `
+ : nothing}
+ `;
}
private _renderHistoryItem: RenderItemFunction<
@@ -156,8 +187,10 @@ export class StateHistoryCharts extends LitElement {
.maxYAxis=${this.maxYAxis}
.fitYData=${this.fitYData}
@y-width-changed=${this._yWidthChanged}
+ @chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
+ hide-reset-button
>
`;
}
@@ -175,6 +208,8 @@ export class StateHistoryCharts extends LitElement {
.chartIndex=${index}
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
+ @chart-zoom-with-index=${this._handleTimelineSync}
+ hide-reset-button
>
`;
};
@@ -264,6 +299,66 @@ export class StateHistoryCharts extends LitElement {
this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0);
}
+ private _handleTimelineSync(
+ e: CustomEvent
+ ) {
+ if (this._isSyncing) {
+ return;
+ }
+
+ const { start, end, chartIndex } = e.detail;
+
+ this._hasZoomedCharts = start !== 0 || end !== 100;
+ this._syncZoomToAllCharts(start, end, chartIndex);
+ }
+
+ private _syncZoomToAllCharts(
+ start: number,
+ end: number,
+ sourceChartIndex?: number
+ ) {
+ this._isSyncing = true;
+
+ requestAnimationFrame(() => {
+ const chartComponents = this.renderRoot.querySelectorAll(
+ "state-history-chart-line, state-history-chart-timeline"
+ ) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
+
+ chartComponents.forEach((chartComponent, index) => {
+ if (index === sourceChartIndex) {
+ return;
+ }
+
+ if ("zoom" in chartComponent) {
+ chartComponent.zoom(start, end);
+ }
+ });
+
+ this._isSyncing = false;
+ });
+ }
+
+ private _handleGlobalZoomReset() {
+ this._hasZoomedCharts = false;
+ this._isSyncing = true;
+
+ requestAnimationFrame(() => {
+ const chartComponents = this.renderRoot.querySelectorAll(
+ "state-history-chart-line, state-history-chart-timeline"
+ );
+
+ chartComponents.forEach((chartComponent: any) => {
+ const chartBase =
+ chartComponent.renderRoot?.querySelector("ha-chart-base");
+
+ if (chartBase && chartBase.chart) {
+ chartBase.zoom(0, 100);
+ }
+ });
+ this._isSyncing = false;
+ });
+ }
+
private _isHistoryEmpty(): boolean {
const historyDataEmpty =
!this.historyData ||
@@ -345,6 +440,11 @@ export class StateHistoryCharts extends LitElement {
state-history-chart-line {
width: 100%;
}
+ .reset-button {
+ position: fixed;
+ bottom: calc(24px + var(--safe-area-inset-bottom));
+ right: calc(24px + var(--safe-area-inset-bottom));
+ }
`;
}