From 5c933a43b2e04bbd787f3ed4d1b46355fb7cbc2d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 25 Feb 2025 12:00:50 +0200 Subject: [PATCH] Custom chart legend (#24227) * Custom chart legend * limit legend label length * fade long legends * tweak margin * new design * fix margins * lighter background * fix variable height charts * tweak legend button * lint * switch to secondary-text-color * Card option to expand legend * Update src/components/chart/ha-chart-base.ts Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com> * pr comments * use ha-assist-chip * pr comments * Apply suggestions from code review Co-authored-by: Bram Kragten --------- Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com> Co-authored-by: Bram Kragten --- src/components/chart/ha-chart-base.ts | 227 ++++++++++++++++-- .../chart/state-history-chart-line.ts | 13 +- src/components/chart/state-history-charts.ts | 6 + src/components/chart/statistics-chart.ts | 47 ++-- .../hui-energy-devices-detail-graph-card.ts | 3 +- .../lovelace/cards/hui-history-graph-card.ts | 4 +- .../cards/hui-statistics-graph-card.ts | 2 +- src/panels/lovelace/cards/types.ts | 2 + src/translations/en.json | 4 +- 9 files changed, 252 insertions(+), 56 deletions(-) diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index a87b7aa3a2..2b4fb39394 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -1,11 +1,12 @@ import { consume } from "@lit-labs/context"; import { ResizeController } from "@lit-labs/observers/resize-controller"; -import { mdiRestart } from "@mdi/js"; +import { mdiChevronDown, mdiChevronUp, mdiRestart } from "@mdi/js"; import { differenceInMinutes } from "date-fns"; import type { DataZoomComponentOption } from "echarts/components"; import type { EChartsType } from "echarts/core"; import type { ECElementEvent, + LegendComponentOption, XAXisOption, YAXisOption, } from "echarts/types/dist/shared"; @@ -25,8 +26,11 @@ import { isMac } from "../../util/is_mac"; import "../ha-icon-button"; import { formatTimeLabel } from "./axis-label"; import { ensureArray } from "../../common/array/ensure-array"; +import "../chips/ha-assist-chip"; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; +const LEGEND_OVERFLOW_LIMIT = 10; +const LEGEND_OVERFLOW_LIMIT_MOBILE = 6; @customElement("ha-chart-base") export class HaChartBase extends LitElement { @@ -40,8 +44,8 @@ export class HaChartBase extends LitElement { @property({ type: String }) public height?: string; - @property({ attribute: "external-hidden", type: Boolean }) - public externalHidden = false; + @property({ attribute: "expand-legend", type: Boolean }) + public expandLegend?: boolean; @state() @consume({ context: themesContext, subscribe: true }) @@ -53,6 +57,8 @@ export class HaChartBase extends LitElement { @state() private _minutesDifference = 24 * 60; + @state() private _hiddenDatasets = new Set(); + private _modifierPressed = false; private _isTouchDevice = "ontouchstart" in window; @@ -135,8 +141,8 @@ export class HaChartBase extends LitElement { return; } let chartOptions: ECOption = {}; - if (changedProps.has("data")) { - chartOptions.series = this.data; + if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) { + chartOptions.series = this._getSeries(); } if (changedProps.has("options")) { chartOptions = { ...chartOptions, ...this._createOptions() }; @@ -151,15 +157,20 @@ export class HaChartBase extends LitElement { protected render() { return html`
-
+
+
+
+ ${this._renderLegend()} ${this._isZoomed ? html` (d.data as any[])?.length && (d.id || d.name)) + .map((d) => d.name ?? d.id) || + []) as string[]; + + const isMobile = window.matchMedia( + "all and (max-width: 450px), all and (max-height: 500px)" + ).matches; + const overflowLimit = isMobile + ? LEGEND_OVERFLOW_LIMIT_MOBILE + : LEGEND_OVERFLOW_LIMIT; + return html`
+
    + ${items.map((item: string, index: number) => { + if (!this.expandLegend && index >= overflowLimit) { + return nothing; + } + const dataset = datasets.find( + (d) => d.id === item || d.name === item + ); + const color = dataset?.color as string; + const borderColor = dataset?.itemStyle?.borderColor as string; + return html`
  • +
    +
    ${item}
    +
  • `; + })} + ${items.length > overflowLimit + ? html`
  • + + + +
  • ` + : nothing} +
+
`; + } + private _formatTimeLabel = (value: number | Date) => formatTimeLabel( value, @@ -195,16 +274,6 @@ export class HaChartBase extends LitElement { echarts.registerTheme("custom", this._createTheme()); this.chart = echarts.init(container, "custom"); - this.chart.on("legendselectchanged", (params: any) => { - if (this.externalHidden) { - const isSelected = params.selected[params.name]; - if (isSelected) { - fireEvent(this, "dataset-unhidden", { name: params.name }); - } else { - fireEvent(this, "dataset-hidden", { name: params.name }); - } - } - }); this.chart.on("datazoom", (e: any) => { const { start, end } = e.batch?.[0] ?? e; this._isZoomed = start !== 0 || end !== 100; @@ -219,7 +288,10 @@ export class HaChartBase extends LitElement { this.chart?.getZr()?.setCursorStyle("default"); } }); - this.chart.setOption({ ...this._createOptions(), series: this.data }); + this.chart.setOption({ + ...this._createOptions(), + series: this._getSeries(), + }); } finally { this._loading = false; } @@ -299,6 +371,9 @@ export class HaChartBase extends LitElement { }, dataZoom: this._getDataZoomConfig(), ...this.options, + legend: { + show: false, + }, xAxis, }; @@ -507,6 +582,15 @@ export class HaChartBase extends LitElement { }; } + private _getSeries() { + if (!Array.isArray(this.data)) { + return this.data; + } + return this.data.filter( + (d) => !this._hiddenDatasets.has(String(d.name ?? d.id)) + ); + } + private _getDefaultHeight() { return Math.max(this.clientWidth / 2, 200); } @@ -540,19 +624,52 @@ export class HaChartBase extends LitElement { this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); } + private _legendClick(ev: any) { + if (!this.chart) { + return; + } + const name = ev.currentTarget?.name; + if (this._hiddenDatasets.has(name)) { + this._hiddenDatasets.delete(name); + fireEvent(this, "dataset-unhidden", { name }); + } else { + this._hiddenDatasets.add(name); + fireEvent(this, "dataset-hidden", { name }); + } + this.requestUpdate("_hiddenDatasets"); + } + + private _toggleExpandedLegend() { + this.expandLegend = !this.expandLegend; + setTimeout(() => { + this.chart?.resize(); + }); + } + static styles = css` :host { display: block; position: relative; letter-spacing: normal; } - .chart-container { + .container { + display: flex; + flex-direction: column; position: relative; + } + .container.has-height { max-height: var(--chart-max-height, 350px); } - .chart { + .chart-container { width: 100%; + max-height: var(--chart-max-height, 350px); + } + .has-height .chart-container { + flex: 1; + } + .chart { height: 100%; + width: 100%; } .zoom-reset { position: absolute; @@ -564,8 +681,66 @@ export class HaChartBase extends LitElement { color: var(--primary-color); border: 1px solid var(--divider-color); } - .has-legend .zoom-reset { - top: 64px; + .chart-legend { + max-height: 60%; + overflow-y: auto; + margin: 12px 0 0; + font-size: 12px; + color: var(--primary-text-color); + } + .chart-legend ul { + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 8px; + } + .chart-legend li { + height: 24px; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0 2px; + box-sizing: border-box; + max-width: 220px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + .chart-legend .hidden { + color: var(--secondary-text-color); + } + .chart-legend .label { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + .chart-legend .bullet { + border-width: 1px; + border-style: solid; + border-radius: 50%; + display: block; + height: 16px; + width: 16px; + margin-right: 4px; + flex-shrink: 0; + box-sizing: border-box; + margin-inline-end: 4px; + margin-inline-start: initial; + direction: var(--direction); + } + .chart-legend .hidden .bullet { + border-color: var(--secondary-text-color) !important; + background-color: transparent !important; + } + ha-assist-chip { + height: 100%; + --_label-text-weight: 500; + --_leading-space: 8px; + --_trailing-space: 8px; + --_icon-label-space: 4px; } `; } diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 27de961e41..e2953937d9 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -63,6 +63,9 @@ export class StateHistoryChartLine extends LitElement { @property({ type: String }) public height?: string; + @property({ attribute: "expand-legend", type: Boolean }) + public expandLegend?: boolean; + @state() private _chartData: LineSeriesOption[] = []; @state() private _entityIds: string[] = []; @@ -87,9 +90,9 @@ export class StateHistoryChartLine extends LitElement { .options=${this._chartOptions} .height=${this.height} style=${styleMap({ height: this.height })} - external-hidden @dataset-hidden=${this._datasetHidden} @dataset-unhidden=${this._datasetUnhidden} + .expandLegend=${this.expandLegend} > `; } @@ -271,16 +274,12 @@ export class StateHistoryChartLine extends LitElement { } as YAXisOption, legend: { show: this.showNames, - type: "scroll", - animationDurationUpdate: 400, - icon: "circle", - padding: [20, 0], }, grid: { - ...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0 + top: 15, left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth), right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1, - bottom: 30, + bottom: 20, }, visualMap: this._visualMap, tooltip: { diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts index 8deb9694a7..7e89aad514 100644 --- a/src/components/chart/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -71,6 +71,9 @@ export class StateHistoryCharts extends LitElement { @property({ type: String }) public height?: string; + @property({ attribute: "expand-legend", type: Boolean }) + public expandLegend?: boolean; + private _computedStartTime!: Date; private _computedEndTime!: Date; @@ -154,6 +157,7 @@ export class StateHistoryCharts extends LitElement { .fitYData=${this.fitYData} @y-width-changed=${this._yWidthChanged} .height=${this.virtualize ? undefined : this.height} + .expandLegend=${this.expandLegend} >
`; } @@ -303,6 +307,8 @@ export class StateHistoryCharts extends LitElement { .entry-container.line { flex: 1; + padding-top: 8px; + overflow: hidden; } .entry-container:hover { diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 5c2372826c..15353a1034 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -1,6 +1,7 @@ import type { BarSeriesOption, LineSeriesOption, + ZRColor, } from "echarts/types/dist/shared"; import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; @@ -90,6 +91,9 @@ export class StatisticsChart extends LitElement { @property({ type: String }) public height?: string; + @property({ attribute: "expand-legend", type: Boolean }) + public expandLegend?: boolean; + @state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = []; @state() private _legendData: string[] = []; @@ -169,9 +173,9 @@ export class StatisticsChart extends LitElement { .options=${this._chartOptions} .height=${this.height} style=${styleMap({ height: this.height })} - external-hidden @dataset-hidden=${this._datasetHidden} @dataset-unhidden=${this._datasetUnhidden} + .expandLegend=${this.expandLegend} > `; } @@ -301,14 +305,10 @@ export class StatisticsChart extends LitElement { }, legend: { show: !this.hideLegend, - type: "scroll", - animationDurationUpdate: 400, - icon: "circle", - padding: [20, 0], data: this._legendData, }, grid: { - ...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0 + top: 15, left: 1, right: 1, bottom: 0, @@ -348,7 +348,11 @@ export class StatisticsChart extends LitElement { let colorIndex = 0; const statisticsData = Object.entries(this.statisticsData); const totalDataSets: typeof this._chartData = []; - const legendData: { name: string; color: string }[] = []; + const legendData: { + name: string; + color?: ZRColor; + borderColor?: ZRColor; + }[] = []; const statisticIds: string[] = []; let endTime: Date; @@ -399,7 +403,7 @@ export class StatisticsChart extends LitElement { // The datasets for the current statistic const statDataSets: (LineSeriesOption | BarSeriesOption)[] = []; - const statLegendData: { name: string; color: string }[] = []; + const statLegendData: typeof legendData = []; const pushData = ( start: Date, @@ -465,15 +469,6 @@ export class StatisticsChart extends LitElement { sortedTypes.forEach((type) => { if (statisticsHaveType(stats, type)) { const band = drawBands && (type === "min" || type === "max"); - if (!this.hideLegend) { - const showLegend = hasMean - ? type === "mean" - : displayedLegend === false; - if (showLegend) { - statLegendData.push({ name, color }); - } - displayedLegend = displayedLegend || showLegend; - } statTypes.push(type); const borderColor = band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color; @@ -517,6 +512,19 @@ export class StatisticsChart extends LitElement { }; } } + if (!this.hideLegend) { + const showLegend = hasMean + ? type === "mean" + : displayedLegend === false; + if (showLegend) { + statLegendData.push({ + name, + color: series.color as ZRColor, + borderColor: series.itemStyle?.borderColor, + }); + } + displayedLegend = displayedLegend || showLegend; + } statDataSets.push(series); statisticIds.push(statistic_id); } @@ -564,12 +572,15 @@ export class StatisticsChart extends LitElement { this.unit = unit; } - legendData.forEach(({ name, color }) => { + legendData.forEach(({ name, color, borderColor }) => { // Add an empty series for the legend totalDataSets.push({ id: name + "-legend", name: name, color, + itemStyle: { + borderColor, + }, type: this.chartType, data: [], xAxisIndex: 1, diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index 9ece46a8d2..c3e64acad6 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -123,7 +123,6 @@ export class HuiEnergyDevicesDetailGraphCard })}" > `} @@ -311,8 +312,9 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { color: var(--primary-text-color); } .content { - padding: 0 16px 8px 16px; + padding: 0 16px 8px; flex: 1; + overflow: hidden; } .has-header { padding-top: 0; diff --git a/src/panels/lovelace/cards/hui-statistics-graph-card.ts b/src/panels/lovelace/cards/hui-statistics-graph-card.ts index a8402a8010..061f8f9b64 100644 --- a/src/panels/lovelace/cards/hui-statistics-graph-card.ts +++ b/src/panels/lovelace/cards/hui-statistics-graph-card.ts @@ -296,6 +296,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { ? differenceInDays(this._energyEnd, this._energyStart) : this._config.days_to_show || DEFAULT_DAYS_TO_SHOW} .height=${hasFixedHeight ? "100%" : undefined} + .expandLegend=${this._config.expand_legend} > @@ -380,7 +381,6 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { } .content { padding: 16px; - padding-top: 0; flex: 1; } .has-header { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 9771067a18..ef6a20f31b 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -359,6 +359,7 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig { max_y_axis?: number; fit_y_data?: boolean; split_device_classes?: boolean; + expand_legend?: boolean; } export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig { @@ -375,6 +376,7 @@ export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig { hide_legend?: boolean; logarithmic_scale?: boolean; energy_date_selection?: boolean; + expand_legend?: boolean; } export interface StatisticCardConfig extends LovelaceCardConfig { diff --git a/src/translations/en.json b/src/translations/en.json index 5bb542bf5a..481fb27f08 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -843,7 +843,9 @@ "duration": "Duration", "source_history": "Source: History", "source_stats": "Source: Long term statistics", - "zoom_reset": "Reset zoom" + "zoom_reset": "Reset zoom", + "expand_legend": "More", + "collapse_legend": "Less" }, "map": { "error": "Unable to load map"