Fixes for echarts (#23906)

* show negative untracked energy again

* fix chart cards height

* fix timeline label width

* fix statistics chart legend

* fix layout of chart cards

* tweak timeline chart labels

* timeline label tweak

* css tweak

* fix legend colors in statistics chart

* dark mode fix

* fix for y axis with a long name

* listen for darkMode changes and update charts

* legend tweak for darkMode

* dark mode tweak

* hide insignificant echarts errors for now
This commit is contained in:
Petar Petrov 2025-01-28 16:20:34 +02:00 committed by GitHub
parent e1bda9b57d
commit cc48ae82d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 129 additions and 88 deletions

View File

@ -7,12 +7,15 @@ import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components"; import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller"; import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared"; import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { consume } from "@lit-labs/context";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac"; import { isMac } from "../../util/is_mac";
import "../ha-icon-button"; import "../ha-icon-button";
import type { ECOption } from "../../resources/echarts"; import type { ECOption } from "../../resources/echarts";
import { listenMediaQuery } from "../../common/dom/media_query"; import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@ -31,6 +34,10 @@ export class HaChartBase extends LitElement {
@property({ attribute: "external-hidden", type: Boolean }) @property({ attribute: "external-hidden", type: Boolean })
public externalHidden = false; public externalHidden = false;
@state()
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@state() private _isZoomed = false; @state() private _isZoomed = false;
private _modifierPressed = false; private _modifierPressed = false;
@ -110,6 +117,10 @@ export class HaChartBase extends LitElement {
if (!this.hasUpdated || !this.chart) { if (!this.hasUpdated || !this.chart) {
return; return;
} }
if (changedProps.has("_themes")) {
this._setupChart();
return;
}
if (changedProps.has("data")) { if (changedProps.has("data")) {
this.chart.setOption( this.chart.setOption(
{ series: this.data }, { series: this.data },
@ -163,9 +174,15 @@ export class HaChartBase extends LitElement {
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement; const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
this._loading = true; this._loading = true;
try { try {
if (this.chart) {
this.chart.dispose();
}
const echarts = (await import("../../resources/echarts")).default; const echarts = (await import("../../resources/echarts")).default;
this.chart = echarts.init(container); this.chart = echarts.init(
container,
this._themes.darkMode ? "dark" : "light"
);
this.chart.on("legendselectchanged", (params: any) => { this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) { if (this.externalHidden) {
const isSelected = params.selected[params.name]; const isSelected = params.selected[params.name];
@ -207,17 +224,10 @@ export class HaChartBase extends LitElement {
} }
private _createOptions(): ECOption { private _createOptions(): ECOption {
const darkMode = this.hass.themes?.darkMode ?? false; const darkMode = this._themes.darkMode ?? false;
const xAxis = Array.isArray(this.options?.xAxis)
? this.options?.xAxis
: [this.options?.xAxis];
const yAxis = Array.isArray(this.options?.yAxis)
? this.options?.yAxis
: [this.options?.yAxis];
// we should create our own theme but this is a quick fix for now
const splitLineStyle = darkMode ? { color: "#333" } : {};
const options = { const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion, animation: !this._reducedMotion,
darkMode, darkMode,
aria: { aria: {
@ -225,32 +235,13 @@ export class HaChartBase extends LitElement {
}, },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
...this.options, ...this.options,
xAxis: xAxis.map((axis) => legend: this.options?.legend
axis ? {
? { // we should create our own theme but this is a quick fix for now
...axis, inactiveColor: darkMode ? "#444" : "#ccc",
splitLine: axis.splitLine ...this.options.legend,
? { }
...axis.splitLine, : undefined,
lineStyle: splitLineStyle,
}
: undefined,
}
: undefined
) as XAXisOption[],
yAxis: yAxis.map((axis) =>
axis
? {
...axis,
splitLine: axis.splitLine
? {
...axis.splitLine,
lineStyle: splitLineStyle,
}
: undefined,
}
: undefined
) as YAXisOption[],
}; };
const isMobile = window.matchMedia( const isMobile = window.matchMedia(

View File

@ -192,7 +192,10 @@ export class StateHistoryChartLine extends LitElement {
max: this.fitYData ? this.maxYAxis : undefined, max: this.fitYData ? this.maxYAxis : undefined,
position: rtl ? "right" : "left", position: rtl ? "right" : "left",
scale: true, scale: true,
nameGap: 3, nameGap: 2,
nameTextStyle: {
align: "left",
},
splitLine: { splitLine: {
show: true, show: true,
lineStyle: splitLineStyle, lineStyle: splitLineStyle,

View File

@ -154,10 +154,6 @@ export class StateHistoryChartTimeline extends LitElement {
}; };
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._createOptions();
}
if ( if (
changedProps.has("startTime") || changedProps.has("startTime") ||
changedProps.has("endTime") || changedProps.has("endTime") ||
@ -171,10 +167,12 @@ export class StateHistoryChartTimeline extends LitElement {
} }
if ( if (
!this.hasUpdated ||
changedProps.has("startTime") || changedProps.has("startTime") ||
changedProps.has("endTime") || changedProps.has("endTime") ||
changedProps.has("showNames") || changedProps.has("showNames") ||
changedProps.has("paddingYAxis") changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) { ) {
this._createOptions(); this._createOptions();
} }
@ -183,9 +181,11 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() { private _createOptions() {
const narrow = this.narrow; const narrow = this.narrow;
const showNames = this.chunked || this.showNames; const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 70 : 165;
const labelWidth = showNames const labelWidth = showNames
? Math.max(narrow ? 70 : 170, this.paddingYAxis) ? Math.max(this.paddingYAxis, this._yWidth)
: 0; : 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass); const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime); const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = { this._chartOptions = {
@ -193,6 +193,12 @@ export class StateHistoryChartTimeline extends LitElement {
type: "time", type: "time",
min: this.startTime, min: this.startTime,
max: this.endTime, max: this.endTime,
axisTick: {
show: true,
lineStyle: {
opacity: 0.4,
},
},
axisLabel: getTimeAxisLabelConfig( axisLabel: getTimeAxisLabelConfig(
this.hass.locale, this.hass.locale,
this.hass.config, this.hass.config,
@ -217,12 +223,14 @@ export class StateHistoryChartTimeline extends LitElement {
}, },
axisLabel: { axisLabel: {
show: showNames, show: showNames,
width: labelWidth, width: labelWidth - labelMargin,
overflow: "truncate", overflow: "truncate",
margin: 5, margin: labelMargin,
// @ts-ignore this is a valid option
formatter: (label: string) => { formatter: (label: string) => {
const width = Math.min(measureTextWidth(label, 12) + 5, labelWidth); const width = Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
);
if (width > this._yWidth) { if (width > this._yWidth) {
this._yWidth = width; this._yWidth = width;
fireEvent(this, "y-width-changed", { fireEvent(this, "y-width-changed", {

View File

@ -2,7 +2,6 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } 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 { styleMap } from "lit/directives/style-map";
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 {
@ -136,10 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``; return html``;
} }
if (!Array.isArray(item)) { if (!Array.isArray(item)) {
return html`<div return html`<div class="entry-container">
class="entry-container"
style=${styleMap({ height: this.height })}
>
<state-history-chart-line <state-history-chart-line
.hass=${this.hass} .hass=${this.hass}
.unit=${item.unit} .unit=${item.unit}
@ -157,7 +153,7 @@ 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}
.height=${this.height} .height=${this.virtualize ? undefined : this.height}
></state-history-chart-line> ></state-history-chart-line>
</div> `; </div> `;
} }
@ -281,7 +277,8 @@ export class StateHistoryCharts extends LitElement {
static styles = css` static styles = css`
:host { :host {
display: block; display: flex;
flex-direction: column;
/* height of single timeline chart = 60px */ /* height of single timeline chart = 60px */
min-height: 60px; min-height: 60px;
} }
@ -302,6 +299,7 @@ export class StateHistoryCharts extends LitElement {
.entry-container { .entry-container {
width: 100%; width: 100%;
flex: 1;
} }
.entry-container:hover { .entry-container:hover {

View File

@ -96,6 +96,8 @@ export class StatisticsChart extends LitElement {
@state() private _chartOptions?: ECOption; @state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
private _computedStyle?: CSSStyleDeclaration; private _computedStyle?: CSSStyleDeclaration;
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
@ -107,7 +109,8 @@ export class StatisticsChart extends LitElement {
changedProps.has("statisticsData") || changedProps.has("statisticsData") ||
changedProps.has("statTypes") || changedProps.has("statTypes") ||
changedProps.has("chartType") || changedProps.has("chartType") ||
changedProps.has("hideLegend") changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) { ) {
this._generateData(); this._generateData();
} }
@ -120,7 +123,8 @@ export class StatisticsChart extends LitElement {
changedProps.has("maxYAxis") || changedProps.has("maxYAxis") ||
changedProps.has("fitYData") || changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") || changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") changedProps.has("hideLegend") ||
changedProps.has("_legendData")
) { ) {
this._createOptions(); this._createOptions();
} }
@ -160,10 +164,23 @@ export class StatisticsChart extends LitElement {
.options=${this._chartOptions} .options=${this._chartOptions}
.height=${this.height} .height=${this.height}
style=${styleMap({ height: this.height })} style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base> ></ha-chart-base>
`; `;
} }
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
this.requestUpdate("_hiddenStats");
}
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
this.requestUpdate("_hiddenStats");
}
private _renderTooltip(params: any) { private _renderTooltip(params: any) {
return params return params
.map((param, index: number) => { .map((param, index: number) => {
@ -219,6 +236,10 @@ export class StatisticsChart extends LitElement {
yAxis: { yAxis: {
type: this.logarithmicScale ? "log" : "value", type: this.logarithmicScale ? "log" : "value",
name: this.unit, name: this.unit,
nameGap: 2,
nameTextStyle: {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left", position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore // @ts-ignore
scale: this.chartType !== "bar", scale: this.chartType !== "bar",
@ -247,13 +268,6 @@ export class StatisticsChart extends LitElement {
appendTo: document.body, appendTo: document.body,
formatter: this._renderTooltip.bind(this), formatter: this._renderTooltip.bind(this),
}, },
// scales: {
// x: {
// ticks: {
// source: this.chartType === "bar" ? "data" : undefined,
// },
// },
// },
}; };
} }
@ -282,8 +296,8 @@ export class StatisticsChart extends LitElement {
let colorIndex = 0; let colorIndex = 0;
const statisticsData = Object.entries(this.statisticsData); const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: LineSeriesOption[] = []; const totalDataSets: typeof this._chartData = [];
const legendData: string[] = []; const legendData: { name: string; color: string }[] = [];
const statisticIds: string[] = []; const statisticIds: string[] = [];
let endTime: Date; let endTime: Date;
@ -334,7 +348,7 @@ export class StatisticsChart extends LitElement {
// The datasets for the current statistic // The datasets for the current statistic
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = []; const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: string[] = []; const statLegendData: { name: string; color: string }[] = [];
const pushData = ( const pushData = (
start: Date, start: Date,
@ -403,7 +417,7 @@ export class StatisticsChart extends LitElement {
? type === "mean" ? type === "mean"
: displayedLegend === false; : displayedLegend === false;
if (showLegend) { if (showLegend) {
statLegendData.push(name); statLegendData.push({ name, color });
} }
displayedLegend = displayedLegend || showLegend; displayedLegend = displayedLegend || showLegend;
} }
@ -415,8 +429,7 @@ export class StatisticsChart extends LitElement {
name: name name: name
? `${name} (${this.hass.localize( ? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}` `ui.components.statistics_charts.statistic_types.${type}`
)}) )})`
`
: this.hass.localize( : this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}` `ui.components.statistics_charts.statistic_types.${type}`
), ),
@ -472,7 +485,9 @@ export class StatisticsChart extends LitElement {
} }
dataValues.push(val); dataValues.push(val);
}); });
pushData(startDate, new Date(stat.end), dataValues); if (!this._hiddenStats.has(name)) {
pushData(startDate, new Date(stat.end), dataValues);
}
}); });
// Concat two arrays // Concat two arrays
@ -484,8 +499,22 @@ export class StatisticsChart extends LitElement {
this.unit = unit; this.unit = unit;
} }
legendData.forEach(({ name, color }) => {
// Add an empty series for the legend
totalDataSets.push({
id: name + "-legend",
name: name,
color,
type: this.chartType,
data: [],
});
});
this._chartData = totalDataSets; this._chartData = totalDataSets;
this._legendData = legendData; if (legendData.length !== this._legendData.length) {
// only update the legend if it has changed or it will trigger options update
this._legendData = legendData.map(({ name }) => name);
}
this._statisticIds = statisticIds; this._statisticIds = statisticIds;
} }

View File

@ -326,14 +326,12 @@ export class HuiEnergyDevicesDetailGraphCard
Object.keys(consumptionData.total).forEach((time) => { Object.keys(consumptionData.total).forEach((time) => {
const value = const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0); consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
if (value > 0) { const dataPoint = [Number(time), value];
const dataPoint = [Number(time), value]; if (compare) {
if (compare) { dataPoint[2] = dataPoint[0];
dataPoint[2] = dataPoint[0]; dataPoint[0] += compareOffset;
dataPoint[0] += compareOffset;
}
untrackedConsumption.push(dataPoint);
} }
untrackedConsumption.push(dataPoint);
}); });
const dataset: BarSeriesOption = { const dataset: BarSeriesOption = {
type: "bar", type: "bar",

View File

@ -64,7 +64,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
getGridOptions(): LovelaceGridOptions { getGridOptions(): LovelaceGridOptions {
return { return {
columns: 12, columns: 12,
rows: 6,
min_columns: 6, min_columns: 6,
min_rows: this._config?.entities?.length || 1, min_rows: this._config?.entities?.length || 1,
}; };
@ -244,6 +243,9 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
start_date: now.toISOString(), start_date: now.toISOString(),
})}`; })}`;
const columns = this._config.grid_options?.columns ?? 12;
const narrow = Number.isNaN(columns) || Number(columns) < 12;
return html` return html`
<ha-card> <ha-card>
${this._config.title ${this._config.title
@ -281,7 +283,10 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis} .minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis} .maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false} .fitYData=${this._config.fit_y_data || false}
height="100%" .height=${this._config.grid_options?.rows
? "100%"
: undefined}
.narrow=${narrow}
></state-history-charts> ></state-history-charts>
`} `}
</div> </div>

View File

@ -131,7 +131,6 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
getGridOptions(): LovelaceGridOptions { getGridOptions(): LovelaceGridOptions {
return { return {
columns: 12, columns: 12,
rows: 5,
min_columns: 6, min_columns: 6,
min_rows: 3, min_rows: 3,
}; };
@ -279,7 +278,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.hideLegend=${this._config.hide_legend || false} .hideLegend=${this._config.hide_legend || false}
.logarithmicScale=${this._config.logarithmic_scale || false} .logarithmicScale=${this._config.logarithmic_scale || false}
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW} .daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
height="100%" .height=${this._config.grid_options?.rows ? "100%" : undefined}
></statistics-chart> ></statistics-chart>
</div> </div>
</ha-card> </ha-card>

View File

@ -29,11 +29,21 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
return; return;
} }
if ( if (
!__DEV__ && // !__DEV__ &&
(ev.message.includes("ResizeObserver loop limit exceeded") || ev.message.includes("ResizeObserver loop limit exceeded") ||
ev.message.includes( ev.message.includes(
"ResizeObserver loop completed with undelivered notifications" "ResizeObserver loop completed with undelivered notifications"
)) ) ||
(ev.error.stack.includes("echarts") &&
(ev.message.includes(
"Cannot read properties of undefined (reading 'hostedBy')"
) ||
ev.message.includes(
"Cannot read properties of undefined (reading 'scale')"
) ||
ev.message.includes(
"Cannot read properties of null (reading 'innerHTML')"
)))
) { ) {
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();

View File

@ -21,5 +21,5 @@ export function measureTextWidth(
} }
context.font = `${fontSize}px ${fontFamily}`; context.font = `${fontSize}px ${fontFamily}`;
return context.measureText(text).width; return Math.ceil(context.measureText(text).width);
} }