mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-09 19:09:48 +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 })
|
||||
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 {
|
||||
</div>
|
||||
${this._renderLegend()}
|
||||
<div class="chart-controls ${classMap({ small: this.smallControls })}">
|
||||
${this._isZoomed
|
||||
${this._isZoomed && !this.hideResetButton
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
.path=${mdiRestart}
|
||||
@@ -353,20 +356,12 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
this.chart = echarts.init(container, "custom");
|
||||
this.chart.on("datazoom", (e: any) => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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}
|
||||
></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() {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
let stateHistory = this.data;
|
||||
|
||||
@@ -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`<div class="container ha-scrollbar" @scroll=${this._saveScrollPos}>
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="ha-scrollbar"
|
||||
.items=${combinedItems}
|
||||
.renderItem=${this._renderHistoryItem}
|
||||
return html`
|
||||
${this.virtualize
|
||||
? html`<div
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</div>`
|
||||
: html`${combinedItems.map((item, index) =>
|
||||
this._renderHistoryItem(item, index)
|
||||
)}`;
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="ha-scrollbar"
|
||||
.items=${combinedItems}
|
||||
.renderItem=${this._renderHistoryItem}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</div>`
|
||||
: html`${combinedItems.map((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<
|
||||
@@ -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
|
||||
></state-history-chart-line>
|
||||
</div> `;
|
||||
}
|
||||
@@ -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
|
||||
></state-history-chart-timeline>
|
||||
</div> `;
|
||||
};
|
||||
@@ -264,6 +299,66 @@ export class StateHistoryCharts extends LitElement {
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user