diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index afc048e5aa..170d933463 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -37,6 +37,26 @@ export default class HaChartBase extends LitElement { @state() private _hiddenDatasets: Set = new Set(); + private _releaseCanvas() { + // release the canvas memory to prevent + // safari from running out of memory. + if (this.chart) { + this.chart.destroy(); + } + } + + public disconnectedCallback() { + this._releaseCanvas(); + super.disconnectedCallback(); + } + + public connectedCallback() { + super.connectedCallback(); + if (this.hasUpdated) { + this._setupChart(); + } + } + protected firstUpdated() { this._setupChart(); this.data.datasets.forEach((dataset, index) => { diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index f355595c1d..f1ca3f4a19 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -28,11 +28,11 @@ class StateHistoryChartLine extends LitElement { @property({ type: Boolean }) public isSingleDevice = false; - @property({ attribute: false }) public endTime?: Date; + @property({ attribute: false }) public endTime!: Date; @state() private _chartData?: ChartData<"line">; - @state() private _chartOptions?: ChartOptions<"line">; + @state() private _chartOptions?: ChartOptions; protected render() { return html` @@ -57,6 +57,7 @@ class StateHistoryChartLine extends LitElement { locale: this.hass.locale, }, }, + suggestedMax: this.endTime, ticks: { maxRotation: 0, sampleSize: 5, @@ -130,28 +131,11 @@ class StateHistoryChartLine extends LitElement { const computedStyles = getComputedStyle(this); const entityStates = this.data; const datasets: ChartDataset<"line">[] = []; - let endTime: Date; - if (entityStates.length === 0) { return; } - endTime = - this.endTime || - // Get the highest date from the last date of each device - new Date( - Math.max( - ...entityStates.map((devSts) => - new Date( - devSts.states[devSts.states.length - 1].last_changed - ).getTime() - ) - ) - ); - if (endTime > new Date()) { - endTime = new Date(); - } - + const endTime = this.endTime; const names = this.names || {}; entityStates.forEach((states) => { const domain = states.domain; diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 413ee0a831..00f7f5cadc 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -83,6 +83,8 @@ export class StateHistoryChartTimeline extends LitElement { @property({ attribute: false }) public data: TimelineEntity[] = []; + @property() public narrow!: boolean; + @property() public names: boolean | Record = false; @property() public unit?: string; @@ -91,7 +93,11 @@ export class StateHistoryChartTimeline extends LitElement { @property({ type: Boolean }) public isSingleDevice = false; - @property({ attribute: false }) public endTime?: Date; + @property({ type: Boolean }) public dataHasMultipleRows = false; + + @property({ attribute: false }) public startTime!: Date; + + @property({ attribute: false }) public endTime!: Date; @state() private _chartData?: ChartData<"timeline">; @@ -110,6 +116,8 @@ export class StateHistoryChartTimeline extends LitElement { public willUpdate(changedProps: PropertyValues) { if (!this.hasUpdated) { + const narrow = this.narrow; + const multipleRows = this.data.length !== 1 || this.dataHasMultipleRows; this._chartOptions = { maintainAspectRatio: false, parsing: false, @@ -123,6 +131,8 @@ export class StateHistoryChartTimeline extends LitElement { locale: this.hass.locale, }, }, + suggestedMin: this.startTime, + suggestedMax: this.endTime, ticks: { autoSkip: true, maxRotation: 0, @@ -153,11 +163,17 @@ export class StateHistoryChartTimeline extends LitElement { drawTicks: false, }, ticks: { - display: this.data.length !== 1, + display: multipleRows, }, afterSetDimensions: (y) => { y.maxWidth = y.chart.width * 0.18; }, + afterFit: function (scaleInstance) { + if (multipleRows) { + // ensure all the chart labels are the same width + scaleInstance.width = narrow ? 105 : 185; + } + }, position: computeRTL(this.hass) ? "right" : "left", }, }, @@ -208,34 +224,8 @@ export class StateHistoryChartTimeline extends LitElement { stateHistory = []; } - const startTime = new Date( - stateHistory.reduce( - (minTime, stateInfo) => - Math.min(minTime, new Date(stateInfo.data[0].last_changed).getTime()), - new Date().getTime() - ) - ); - - // end time is Math.max(startTime, last_event) - let endTime = - this.endTime || - new Date( - stateHistory.reduce( - (maxTime, stateInfo) => - Math.max( - maxTime, - new Date( - stateInfo.data[stateInfo.data.length - 1].last_changed - ).getTime() - ), - startTime.getTime() - ) - ); - - if (endTime > new Date()) { - endTime = new Date(); - } - + const startTime = this.startTime; + const endTime = this.endTime; const labels: string[] = []; const datasets: ChartDataset<"timeline">[] = []; const names = this.names || {}; diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts index ae5d7844eb..58cb0b321d 100644 --- a/src/components/chart/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -1,3 +1,4 @@ +import "@lit-labs/virtualizer"; import { css, CSSResultGroup, @@ -6,12 +7,29 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state, eventOptions } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; -import { HistoryResult } from "../../data/history"; +import { + HistoryResult, + LineChartUnit, + TimelineEntity, +} from "../../data/history"; import type { HomeAssistant } from "../../types"; import "./state-history-chart-line"; import "./state-history-chart-timeline"; +import { restoreScroll } from "../../common/decorators/restore-scroll"; + +const CANVAS_TIMELINE_ROWS_CHUNK = 10; // Split up the canvases to avoid hitting the render limit + +const chunkData = (inputArray: any[], chunks: number) => + inputArray.reduce((results, item, idx) => { + const chunkIdx = Math.floor(idx / chunks); + if (!results[chunkIdx]) { + results[chunkIdx] = []; + } + results[chunkIdx].push(item); + return results; + }, []); @customElement("state-history-charts") class StateHistoryCharts extends LitElement { @@ -19,8 +37,13 @@ class StateHistoryCharts extends LitElement { @property({ attribute: false }) public historyData!: HistoryResult; + @property() public narrow!: boolean; + @property({ type: Boolean }) public names = false; + @property({ type: Boolean, attribute: "virtualize", reflect: true }) + public virtualize = false; + @property({ attribute: false }) public endTime?: Date; @property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false; @@ -29,6 +52,14 @@ class StateHistoryCharts extends LitElement { @property({ type: Boolean }) public isLoadingData = false; + @state() private _computedStartTime!: Date; + + @state() private _computedEndTime!: Date; + + // @ts-ignore + @restoreScroll(".container") private _savedScrollPos?: number; + + @eventOptions({ passive: true }) protected render(): TemplateResult { if (!isComponentLoaded(this.hass, "history")) { return html`
@@ -48,40 +79,76 @@ class StateHistoryCharts extends LitElement {
`; } - const computedEndTime = this.upToNow - ? new Date() - : this.endTime || new Date(); + const now = new Date(); - return html` - ${this.historyData.timeline.length - ? html` - - ` - : html``} - ${this.historyData.line.map( - (line) => html` - - ` - )} - `; + this._computedEndTime = + this.upToNow || !this.endTime || this.endTime > now ? now : this.endTime; + + this._computedStartTime = new Date( + this.historyData.timeline.reduce( + (minTime, stateInfo) => + Math.min(minTime, new Date(stateInfo.data[0].last_changed).getTime()), + new Date().getTime() + ) + ); + + const combinedItems = chunkData( + this.historyData.timeline, + CANVAS_TIMELINE_ROWS_CHUNK + ).concat(this.historyData.line); + + return this.virtualize + ? html`
+ + +
` + : html`${combinedItems.map((item, index) => + this._renderHistoryItem(item, index) + )}`; } + private _renderHistoryItem = ( + item: TimelineEntity[] | LineChartUnit, + index: number + ): TemplateResult => { + if (!item || index === undefined) { + return html``; + } + if (!Array.isArray(item)) { + return html`
+ +
`; + } + return html`
+ 1} + > +
`; + }; + protected shouldUpdate(changedProps: PropertyValues): boolean { return !(changedProps.size === 1 && changedProps.has("hass")); } @@ -96,6 +163,11 @@ class StateHistoryCharts extends LitElement { return !this.isLoadingData && historyDataEmpty; } + @eventOptions({ passive: true }) + private _saveScrollPos(e: Event) { + this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; + } + static get styles(): CSSResultGroup { return css` :host { @@ -103,11 +175,47 @@ class StateHistoryCharts extends LitElement { /* height of single timeline chart = 60px */ min-height: 60px; } + + :host([virtualize]) { + height: 100%; + } + .info { text-align: center; line-height: 60px; color: var(--secondary-text-color); } + .container { + max-height: var(--history-max-height); + } + + .entry-container { + width: 100%; + } + + .entry-container:hover { + z-index: 1; + } + + :host([virtualize]) .entry-container { + padding-left: 1px; + padding-right: 1px; + } + + .container, + lit-virtualizer { + height: 100%; + width: 100%; + } + + lit-virtualizer { + contain: size layout !important; + } + + state-history-chart-timeline, + state-history-chart-line { + width: 100%; + } `; } } diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index a1c4554990..7caa849c0a 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -80,44 +80,44 @@ class HaPanelHistory extends LitElement { -
-
- +
+ - -
- ${this._isLoading - ? html`
- -
` - : html` - - - `} +
+ ${this._isLoading + ? html`
+ +
` + : html` + + + `} `; } @@ -235,6 +235,14 @@ class HaPanelHistory extends LitElement { padding: 0 16px 16px; } + state-history-charts { + height: calc(100vh - 136px); + } + + :host([narrow]) state-history-charts { + height: calc(100vh - 198px); + } + .progress-wrapper { height: calc(100vh - 136px); } @@ -243,6 +251,10 @@ class HaPanelHistory extends LitElement { height: calc(100vh - 198px); } + :host([virtualize]) { + height: 100%; + } + .progress-wrapper { position: relative; }