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 { 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<string>();
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`
<div
class=${classMap({
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
class="container ${classMap({ "has-height": !!this.height })}"
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
? html`<ha-icon-button
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) =>
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;
}
`;
}

View File

@ -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}
></ha-chart-base>
`;
}
@ -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: {

View File

@ -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}
></state-history-chart-line>
</div> `;
}
@ -303,6 +307,8 @@ export class StateHistoryCharts extends LitElement {
.entry-container.line {
flex: 1;
padding-top: 8px;
overflow: hidden;
}
.entry-container:hover {

View File

@ -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}
></ha-chart-base>
`;
}
@ -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,

View File

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

View File

@ -287,6 +287,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.fitYData=${this._config.fit_y_data || false}
.height=${hasFixedHeight ? "100%" : undefined}
.narrow=${narrow}
.expandLegend=${this._config.expand_legend}
></state-history-charts>
`}
</div>
@ -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;

View File

@ -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}
></statistics-chart>
</div>
</ha-card>
@ -380,7 +381,6 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
}
.content {
padding: 16px;
padding-top: 0;
flex: 1;
}
.has-header {

View File

@ -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 {

View File

@ -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"