mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-10 11:30:01 +00:00
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
This commit is contained in:
@@ -63,6 +63,9 @@ export class HaChartBase extends LitElement {
|
|||||||
@property({ attribute: "small-controls", type: Boolean })
|
@property({ attribute: "small-controls", type: Boolean })
|
||||||
public smallControls?: boolean;
|
public smallControls?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||||
|
public hideResetButton?: boolean;
|
||||||
|
|
||||||
// extraComponents is not reactive and should not trigger updates
|
// extraComponents is not reactive and should not trigger updates
|
||||||
public extraComponents?: any[];
|
public extraComponents?: any[];
|
||||||
|
|
||||||
@@ -215,7 +218,7 @@ export class HaChartBase extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
${this._renderLegend()}
|
${this._renderLegend()}
|
||||||
<div class="chart-controls ${classMap({ small: this.smallControls })}">
|
<div class="chart-controls ${classMap({ small: this.smallControls })}">
|
||||||
${this._isZoomed
|
${this._isZoomed && !this.hideResetButton
|
||||||
? html`<ha-icon-button
|
? html`<ha-icon-button
|
||||||
class="zoom-reset"
|
class="zoom-reset"
|
||||||
.path=${mdiRestart}
|
.path=${mdiRestart}
|
||||||
@@ -353,20 +356,12 @@ export class HaChartBase extends LitElement {
|
|||||||
|
|
||||||
this.chart = echarts.init(container, "custom");
|
this.chart = echarts.init(container, "custom");
|
||||||
this.chart.on("datazoom", (e: any) => {
|
this.chart.on("datazoom", (e: any) => {
|
||||||
const { start, end } = e.batch?.[0] ?? e;
|
this._handleDataZoomEvent(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.chart.on("click", (e: ECElementEvent) => {
|
this.chart.on("click", (e: ECElementEvent) => {
|
||||||
fireEvent(this, "chart-click", e);
|
fireEvent(this, "chart-click", e);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.options?.dataZoom) {
|
if (!this.options?.dataZoom) {
|
||||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
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() {
|
private _handleZoomReset() {
|
||||||
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
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) {
|
private _legendClick(ev: any) {
|
||||||
if (!this.chart) {
|
if (!this.chart) {
|
||||||
return;
|
return;
|
||||||
@@ -1024,5 +1069,9 @@ declare global {
|
|||||||
"dataset-hidden": { id: string };
|
"dataset-hidden": { id: string };
|
||||||
"dataset-unhidden": { id: string };
|
"dataset-unhidden": { id: string };
|
||||||
"chart-click": ECElementEvent;
|
"chart-click": ECElementEvent;
|
||||||
|
"chart-zoom": {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
@property({ attribute: "expand-legend", type: Boolean })
|
@property({ attribute: "expand-legend", type: Boolean })
|
||||||
public expandLegend?: boolean;
|
public expandLegend?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||||
|
public hideResetButton?: boolean;
|
||||||
|
|
||||||
@state() private _chartData: LineSeriesOption[] = [];
|
@state() private _chartData: LineSeriesOption[] = [];
|
||||||
|
|
||||||
@state() private _entityIds: string[] = [];
|
@state() private _entityIds: string[] = [];
|
||||||
@@ -94,7 +97,9 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
style=${styleMap({ height: this.height })}
|
style=${styleMap({ height: this.height })}
|
||||||
@dataset-hidden=${this._datasetHidden}
|
@dataset-hidden=${this._datasetHidden}
|
||||||
@dataset-unhidden=${this._datasetUnhidden}
|
@dataset-unhidden=${this._datasetUnhidden}
|
||||||
|
@chart-zoom=${this._handleDataZoom}
|
||||||
.expandLegend=${this.expandLegend}
|
.expandLegend=${this.expandLegend}
|
||||||
|
.hideResetButton=${this.hideResetButton}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -192,6 +197,19 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
this._hiddenStats.delete(ev.detail.id);
|
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) {
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
if (
|
if (
|
||||||
changedProps.has("data") ||
|
changedProps.has("data") ||
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false, type: Number }) public chartIndex?;
|
@property({ attribute: false, type: Number }) public chartIndex?;
|
||||||
|
|
||||||
|
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||||
|
public hideResetButton?: boolean;
|
||||||
|
|
||||||
@state() private _chartData: CustomSeriesOption[] = [];
|
@state() private _chartData: CustomSeriesOption[] = [];
|
||||||
|
|
||||||
@state() private _chartOptions?: ECOption;
|
@state() private _chartOptions?: ECOption;
|
||||||
@@ -68,6 +71,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
.data=${this._chartData as ECOption["series"]}
|
.data=${this._chartData as ECOption["series"]}
|
||||||
small-controls
|
small-controls
|
||||||
@chart-click=${this._handleChartClick}
|
@chart-click=${this._handleChartClick}
|
||||||
|
@chart-zoom=${this._handleDataZoom}
|
||||||
|
.hideResetButton=${this.hideResetButton}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -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() {
|
private _generateData() {
|
||||||
const computedStyles = getComputedStyle(this);
|
const computedStyles = getComputedStyle(this);
|
||||||
let stateHistory = this.data;
|
let stateHistory = this.data;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { PropertyValues } from "lit";
|
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 { customElement, eventOptions, property, state } from "lit/decorators";
|
||||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||||
|
import { mdiRestart } from "@mdi/js";
|
||||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +12,10 @@ import type {
|
|||||||
} from "../../data/history";
|
} from "../../data/history";
|
||||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||||
import type { HomeAssistant } from "../../types";
|
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-line";
|
||||||
import "./state-history-chart-timeline";
|
import "./state-history-chart-timeline";
|
||||||
|
|
||||||
@@ -29,6 +34,11 @@ const chunkData = (inputArray: any[], chunks: number) =>
|
|||||||
declare global {
|
declare global {
|
||||||
interface HASSDomEvents {
|
interface HASSDomEvents {
|
||||||
"y-width-changed": { value: number; chartIndex: number };
|
"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 _chartCount = 0;
|
||||||
|
|
||||||
|
@state() private _hasZoomedCharts = false;
|
||||||
|
|
||||||
|
private _isSyncing = false;
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@restoreScroll(".container") private _savedScrollPos?: number;
|
@restoreScroll(".container") private _savedScrollPos?: number;
|
||||||
|
|
||||||
@@ -115,8 +129,12 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
// eslint-disable-next-line lit/no-this-assign-in-render
|
// eslint-disable-next-line lit/no-this-assign-in-render
|
||||||
this._chartCount = combinedItems.length;
|
this._chartCount = combinedItems.length;
|
||||||
|
|
||||||
return this.virtualize
|
return html`
|
||||||
? html`<div class="container ha-scrollbar" @scroll=${this._saveScrollPos}>
|
${this.virtualize
|
||||||
|
? html`<div
|
||||||
|
class="container ha-scrollbar"
|
||||||
|
@scroll=${this._saveScrollPos}
|
||||||
|
>
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
scroller
|
scroller
|
||||||
class="ha-scrollbar"
|
class="ha-scrollbar"
|
||||||
@@ -127,7 +145,20 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
</div>`
|
</div>`
|
||||||
: html`${combinedItems.map((item, index) =>
|
: html`${combinedItems.map((item, index) =>
|
||||||
this._renderHistoryItem(item, index)
|
this._renderHistoryItem(item, index)
|
||||||
)}`;
|
)}`}
|
||||||
|
${this._hasZoomedCharts
|
||||||
|
? html`<ha-fab
|
||||||
|
slot="fab"
|
||||||
|
class="reset-button"
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.components.history_charts.zoom_reset"
|
||||||
|
)}
|
||||||
|
@click=${this._handleGlobalZoomReset}
|
||||||
|
>
|
||||||
|
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
|
||||||
|
</ha-fab>`
|
||||||
|
: nothing}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderHistoryItem: RenderItemFunction<
|
private _renderHistoryItem: RenderItemFunction<
|
||||||
@@ -156,8 +187,10 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
.maxYAxis=${this.maxYAxis}
|
.maxYAxis=${this.maxYAxis}
|
||||||
.fitYData=${this.fitYData}
|
.fitYData=${this.fitYData}
|
||||||
@y-width-changed=${this._yWidthChanged}
|
@y-width-changed=${this._yWidthChanged}
|
||||||
|
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||||
.height=${this.virtualize ? undefined : this.height}
|
.height=${this.virtualize ? undefined : this.height}
|
||||||
.expandLegend=${this.expandLegend}
|
.expandLegend=${this.expandLegend}
|
||||||
|
hide-reset-button
|
||||||
></state-history-chart-line>
|
></state-history-chart-line>
|
||||||
</div> `;
|
</div> `;
|
||||||
}
|
}
|
||||||
@@ -175,6 +208,8 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
.chartIndex=${index}
|
.chartIndex=${index}
|
||||||
.clickForMoreInfo=${this.clickForMoreInfo}
|
.clickForMoreInfo=${this.clickForMoreInfo}
|
||||||
@y-width-changed=${this._yWidthChanged}
|
@y-width-changed=${this._yWidthChanged}
|
||||||
|
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||||
|
hide-reset-button
|
||||||
></state-history-chart-timeline>
|
></state-history-chart-timeline>
|
||||||
</div> `;
|
</div> `;
|
||||||
};
|
};
|
||||||
@@ -264,6 +299,66 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0);
|
this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleTimelineSync(
|
||||||
|
e: CustomEvent<HASSDomEvents["chart-zoom-with-index"]>
|
||||||
|
) {
|
||||||
|
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 {
|
private _isHistoryEmpty(): boolean {
|
||||||
const historyDataEmpty =
|
const historyDataEmpty =
|
||||||
!this.historyData ||
|
!this.historyData ||
|
||||||
@@ -345,6 +440,11 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
state-history-chart-line {
|
state-history-chart-line {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.reset-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(24px + var(--safe-area-inset-bottom));
|
||||||
|
right: calc(24px + var(--safe-area-inset-bottom));
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user