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 <mail@bramkragten.nl>

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Petar Petrov 2025-02-25 12:00:50 +02:00 committed by GitHub
parent 151a7fbc40
commit 5c933a43b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 252 additions and 56 deletions

View File

@ -1,11 +1,12 @@
import { consume } from "@lit-labs/context"; import { consume } from "@lit-labs/context";
import { ResizeController } from "@lit-labs/observers/resize-controller"; 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 { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components"; import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core"; import type { EChartsType } from "echarts/core";
import type { import type {
ECElementEvent, ECElementEvent,
LegendComponentOption,
XAXisOption, XAXisOption,
YAXisOption, YAXisOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
@ -25,8 +26,11 @@ import { isMac } from "../../util/is_mac";
import "../ha-icon-button"; import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label"; import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import "../chips/ha-assist-chip";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
@customElement("ha-chart-base") @customElement("ha-chart-base")
export class HaChartBase extends LitElement { export class HaChartBase extends LitElement {
@ -40,8 +44,8 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string; @property({ type: String }) public height?: string;
@property({ attribute: "external-hidden", type: Boolean }) @property({ attribute: "expand-legend", type: Boolean })
public externalHidden = false; public expandLegend?: boolean;
@state() @state()
@consume({ context: themesContext, subscribe: true }) @consume({ context: themesContext, subscribe: true })
@ -53,6 +57,8 @@ export class HaChartBase extends LitElement {
@state() private _minutesDifference = 24 * 60; @state() private _minutesDifference = 24 * 60;
@state() private _hiddenDatasets = new Set<string>();
private _modifierPressed = false; private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window; private _isTouchDevice = "ontouchstart" in window;
@ -135,8 +141,8 @@ export class HaChartBase extends LitElement {
return; return;
} }
let chartOptions: ECOption = {}; let chartOptions: ECOption = {};
if (changedProps.has("data")) { if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this.data; chartOptions.series = this._getSeries();
} }
if (changedProps.has("options")) { if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() }; chartOptions = { ...chartOptions, ...this._createOptions() };
@ -151,15 +157,20 @@ export class HaChartBase extends LitElement {
protected render() { protected render() {
return html` return html`
<div <div
class=${classMap({ class="container ${classMap({ "has-height": !!this.height })}"
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
style=${styleMap({ style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`, height: this.height,
})} })}
> >
<div class="chart"></div> <div
class="chart-container"
style=${styleMap({
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
</div>
${this._renderLegend()}
${this._isZoomed ${this._isZoomed
? html`<ha-icon-button ? html`<ha-icon-button
class="zoom-reset" class="zoom-reset"
@ -174,6 +185,74 @@ export class HaChartBase extends LitElement {
`; `;
} }
private _renderLegend() {
if (!this.options?.legend || !this.data) {
return nothing;
}
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show) {
return nothing;
}
const datasets = ensureArray(this.data);
const items = (legend.data ||
datasets
.filter((d) => (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`<div class="chart-legend">
<ul>
${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`<li
.name=${item}
@click=${this._legendClick}
class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
.title=${item}
>
<div
class="bullet"
style=${styleMap({
backgroundColor: color,
borderColor: borderColor || color,
})}
></div>
<div class="label">${item}</div>
</li>`;
})}
${items.length > overflowLimit
? html`<li>
<ha-assist-chip
@click=${this._toggleExpandedLegend}
filled
label=${`${this.hass.localize(
`ui.components.history_charts.${this.expandLegend ? "collapse_legend" : "expand_legend"}`
)} (${items.length})`}
>
<ha-svg-icon
slot="trailing-icon"
.path=${this.expandLegend ? mdiChevronUp : mdiChevronDown}
></ha-svg-icon>
</ha-assist-chip>
</li>`
: nothing}
</ul>
</div>`;
}
private _formatTimeLabel = (value: number | Date) => private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel( formatTimeLabel(
value, value,
@ -195,16 +274,6 @@ export class HaChartBase extends LitElement {
echarts.registerTheme("custom", this._createTheme()); echarts.registerTheme("custom", this._createTheme());
this.chart = echarts.init(container, "custom"); 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) => { this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e; const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100; this._isZoomed = start !== 0 || end !== 100;
@ -219,7 +288,10 @@ export class HaChartBase extends LitElement {
this.chart?.getZr()?.setCursorStyle("default"); this.chart?.getZr()?.setCursorStyle("default");
} }
}); });
this.chart.setOption({ ...this._createOptions(), series: this.data }); this.chart.setOption({
...this._createOptions(),
series: this._getSeries(),
});
} finally { } finally {
this._loading = false; this._loading = false;
} }
@ -299,6 +371,9 @@ export class HaChartBase extends LitElement {
}, },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
...this.options, ...this.options,
legend: {
show: false,
},
xAxis, 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() { private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 200); 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 }); 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` static styles = css`
:host { :host {
display: block; display: block;
position: relative; position: relative;
letter-spacing: normal; letter-spacing: normal;
} }
.chart-container { .container {
display: flex;
flex-direction: column;
position: relative; position: relative;
}
.container.has-height {
max-height: var(--chart-max-height, 350px); max-height: var(--chart-max-height, 350px);
} }
.chart { .chart-container {
width: 100%; width: 100%;
max-height: var(--chart-max-height, 350px);
}
.has-height .chart-container {
flex: 1;
}
.chart {
height: 100%; height: 100%;
width: 100%;
} }
.zoom-reset { .zoom-reset {
position: absolute; position: absolute;
@ -564,8 +681,66 @@ export class HaChartBase extends LitElement {
color: var(--primary-color); color: var(--primary-color);
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
} }
.has-legend .zoom-reset { .chart-legend {
top: 64px; 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;
} }
`; `;
} }

View File

@ -63,6 +63,9 @@ export class StateHistoryChartLine extends LitElement {
@property({ type: String }) public height?: string; @property({ type: String }) public height?: string;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@state() private _chartData: LineSeriesOption[] = []; @state() private _chartData: LineSeriesOption[] = [];
@state() private _entityIds: string[] = []; @state() private _entityIds: string[] = [];
@ -87,9 +90,9 @@ export class StateHistoryChartLine 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-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden} @dataset-unhidden=${this._datasetUnhidden}
.expandLegend=${this.expandLegend}
></ha-chart-base> ></ha-chart-base>
`; `;
} }
@ -271,16 +274,12 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption, } as YAXisOption,
legend: { legend: {
show: this.showNames, show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
}, },
grid: { grid: {
...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0 top: 15,
left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth), left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth),
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1, right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30, bottom: 20,
}, },
visualMap: this._visualMap, visualMap: this._visualMap,
tooltip: { tooltip: {

View File

@ -71,6 +71,9 @@ export class StateHistoryCharts extends LitElement {
@property({ type: String }) public height?: string; @property({ type: String }) public height?: string;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
private _computedStartTime!: Date; private _computedStartTime!: Date;
private _computedEndTime!: Date; private _computedEndTime!: Date;
@ -154,6 +157,7 @@ export class StateHistoryCharts extends LitElement {
.fitYData=${this.fitYData} .fitYData=${this.fitYData}
@y-width-changed=${this._yWidthChanged} @y-width-changed=${this._yWidthChanged}
.height=${this.virtualize ? undefined : this.height} .height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
></state-history-chart-line> ></state-history-chart-line>
</div> `; </div> `;
} }
@ -303,6 +307,8 @@ export class StateHistoryCharts extends LitElement {
.entry-container.line { .entry-container.line {
flex: 1; flex: 1;
padding-top: 8px;
overflow: hidden;
} }
.entry-container:hover { .entry-container:hover {

View File

@ -1,6 +1,7 @@
import type { import type {
BarSeriesOption, BarSeriesOption,
LineSeriesOption, LineSeriesOption,
ZRColor,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
@ -90,6 +91,9 @@ export class StatisticsChart extends LitElement {
@property({ type: String }) public height?: string; @property({ type: String }) public height?: string;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = []; @state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: string[] = []; @state() private _legendData: string[] = [];
@ -169,9 +173,9 @@ 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-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden} @dataset-unhidden=${this._datasetUnhidden}
.expandLegend=${this.expandLegend}
></ha-chart-base> ></ha-chart-base>
`; `;
} }
@ -301,14 +305,10 @@ export class StatisticsChart extends LitElement {
}, },
legend: { legend: {
show: !this.hideLegend, show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
data: this._legendData, data: this._legendData,
}, },
grid: { grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0 top: 15,
left: 1, left: 1,
right: 1, right: 1,
bottom: 0, bottom: 0,
@ -348,7 +348,11 @@ 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: typeof this._chartData = []; const totalDataSets: typeof this._chartData = [];
const legendData: { name: string; color: string }[] = []; const legendData: {
name: string;
color?: ZRColor;
borderColor?: ZRColor;
}[] = [];
const statisticIds: string[] = []; const statisticIds: string[] = [];
let endTime: Date; let endTime: Date;
@ -399,7 +403,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: { name: string; color: string }[] = []; const statLegendData: typeof legendData = [];
const pushData = ( const pushData = (
start: Date, start: Date,
@ -465,15 +469,6 @@ export class StatisticsChart extends LitElement {
sortedTypes.forEach((type) => { sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) { if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === "min" || type === "max"); 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); statTypes.push(type);
const borderColor = const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color; 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); statDataSets.push(series);
statisticIds.push(statistic_id); statisticIds.push(statistic_id);
} }
@ -564,12 +572,15 @@ export class StatisticsChart extends LitElement {
this.unit = unit; this.unit = unit;
} }
legendData.forEach(({ name, color }) => { legendData.forEach(({ name, color, borderColor }) => {
// Add an empty series for the legend // Add an empty series for the legend
totalDataSets.push({ totalDataSets.push({
id: name + "-legend", id: name + "-legend",
name: name, name: name,
color, color,
itemStyle: {
borderColor,
},
type: this.chartType, type: this.chartType,
data: [], data: [],
xAxisIndex: 1, xAxisIndex: 1,

View File

@ -123,7 +123,6 @@ export class HuiEnergyDevicesDetailGraphCard
})}" })}"
> >
<ha-chart-base <ha-chart-base
external-hidden
.hass=${this.hass} .hass=${this.hass}
.data=${this._chartData} .data=${this._chartData}
.options=${this._createOptions( .options=${this._createOptions(
@ -193,7 +192,7 @@ export class HuiEnergyDevicesDetailGraphCard
icon: "circle", icon: "circle",
}, },
grid: { grid: {
top: 45, top: 15,
bottom: 0, bottom: 0,
left: 1, left: 1,
right: 1, right: 1,

View File

@ -287,6 +287,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.fitYData=${this._config.fit_y_data || false} .fitYData=${this._config.fit_y_data || false}
.height=${hasFixedHeight ? "100%" : undefined} .height=${hasFixedHeight ? "100%" : undefined}
.narrow=${narrow} .narrow=${narrow}
.expandLegend=${this._config.expand_legend}
></state-history-charts> ></state-history-charts>
`} `}
</div> </div>
@ -311,8 +312,9 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
color: var(--primary-text-color); color: var(--primary-text-color);
} }
.content { .content {
padding: 0 16px 8px 16px; padding: 0 16px 8px;
flex: 1; flex: 1;
overflow: hidden;
} }
.has-header { .has-header {
padding-top: 0; padding-top: 0;

View File

@ -296,6 +296,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
? differenceInDays(this._energyEnd, this._energyStart) ? differenceInDays(this._energyEnd, this._energyStart)
: this._config.days_to_show || DEFAULT_DAYS_TO_SHOW} : this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
.height=${hasFixedHeight ? "100%" : undefined} .height=${hasFixedHeight ? "100%" : undefined}
.expandLegend=${this._config.expand_legend}
></statistics-chart> ></statistics-chart>
</div> </div>
</ha-card> </ha-card>
@ -380,7 +381,6 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
} }
.content { .content {
padding: 16px; padding: 16px;
padding-top: 0;
flex: 1; flex: 1;
} }
.has-header { .has-header {

View File

@ -359,6 +359,7 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig {
max_y_axis?: number; max_y_axis?: number;
fit_y_data?: boolean; fit_y_data?: boolean;
split_device_classes?: boolean; split_device_classes?: boolean;
expand_legend?: boolean;
} }
export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig { export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig {
@ -375,6 +376,7 @@ export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig {
hide_legend?: boolean; hide_legend?: boolean;
logarithmic_scale?: boolean; logarithmic_scale?: boolean;
energy_date_selection?: boolean; energy_date_selection?: boolean;
expand_legend?: boolean;
} }
export interface StatisticCardConfig extends LovelaceCardConfig { export interface StatisticCardConfig extends LovelaceCardConfig {

View File

@ -843,7 +843,9 @@
"duration": "Duration", "duration": "Duration",
"source_history": "Source: History", "source_history": "Source: History",
"source_stats": "Source: Long term statistics", "source_stats": "Source: Long term statistics",
"zoom_reset": "Reset zoom" "zoom_reset": "Reset zoom",
"expand_legend": "More",
"collapse_legend": "Less"
}, },
"map": { "map": {
"error": "Unable to load map" "error": "Unable to load map"