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)); + } `; }