Skip to content

Commit b201546

Browse files
authored
Add stacked chart types to Statistics Graph Card (#51530)
* Add stacked mode to statistics-chart Allows displaying the entries as stacked lines or stacked bars. This means we can create charts similar to the energy graph cards but with alternate entities. * Move fillDataGapsAndRoundCaps to components/chart Now used in statistics-chart too, so move to common chart location. Re-export in energy-chart-options.ts to minimise changes throughout energy cards. * Correct order of line/bar in statistics graph card editor Line and Bar options were unintentionally reversed in the displayed list. * Support unstacked bar charts in fillDataGapsAndRoundCaps
1 parent 8e4c990 commit b201546

7 files changed

Lines changed: 166 additions & 116 deletions

File tree

src/components/chart/round-caps.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { BarSeriesOption } from "echarts/types/dist/shared";
2+
3+
export function fillDataGapsAndRoundCaps(
4+
datasets: BarSeriesOption[],
5+
stacked = true
6+
) {
7+
if (!stacked) {
8+
// For non-stacked charts, we can simply apply an overall border to each stack
9+
// to curve the top of the bar, and then override on any negative bars.
10+
datasets.forEach((dataset) => {
11+
// Add upper border radius to stack
12+
dataset.itemStyle = {
13+
...dataset.itemStyle,
14+
borderRadius: [4, 4, 0, 0],
15+
};
16+
// And override any negative points to have bottom border curved
17+
for (let pointIdx = 0; pointIdx < dataset.data!.length; pointIdx++) {
18+
const dataPoint = dataset.data![pointIdx];
19+
const item: any =
20+
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
21+
? dataPoint
22+
: { value: dataPoint };
23+
if (item.value?.[1] < 0) {
24+
dataset.data![pointIdx] = {
25+
...item,
26+
itemStyle: {
27+
...item.itemStyle,
28+
borderRadius: [0, 0, 4, 4],
29+
},
30+
};
31+
}
32+
}
33+
});
34+
return;
35+
}
36+
37+
// For stacked charts, we need to carefully work through the data points in each
38+
// stack to ensure only the lowermost negative and uppermost positive values have
39+
// a curved border.
40+
const buckets = Array.from(
41+
new Set(
42+
datasets
43+
.map((dataset) =>
44+
dataset.data!.map((datapoint) => Number(datapoint![0]))
45+
)
46+
.flat()
47+
)
48+
).sort((a, b) => a - b);
49+
50+
// make sure all datasets have the same buckets
51+
// otherwise the chart will render incorrectly in some cases
52+
buckets.forEach((bucket, index) => {
53+
const capRounded = {};
54+
const capRoundedNegative = {};
55+
for (let i = datasets.length - 1; i >= 0; i--) {
56+
const dataPoint = datasets[i].data![index];
57+
const item: any =
58+
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
59+
? dataPoint
60+
: { value: dataPoint };
61+
const x = item.value?.[0];
62+
const stack = datasets[i].stack ?? "";
63+
if (x === undefined) {
64+
continue;
65+
}
66+
if (Number(x) !== bucket) {
67+
datasets[i].data?.splice(index, 0, {
68+
value: [bucket, 0],
69+
itemStyle: {
70+
borderWidth: 0,
71+
},
72+
});
73+
} else if (item.value?.[1] === 0) {
74+
// remove the border for zero values or it will be rendered
75+
datasets[i].data![index] = {
76+
...item,
77+
itemStyle: {
78+
...item.itemStyle,
79+
borderWidth: 0,
80+
},
81+
};
82+
} else if (!capRounded[stack] && item.value?.[1] > 0) {
83+
datasets[i].data![index] = {
84+
...item,
85+
itemStyle: {
86+
...item.itemStyle,
87+
borderRadius: [4, 4, 0, 0],
88+
},
89+
};
90+
capRounded[stack] = true;
91+
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
92+
datasets[i].data![index] = {
93+
...item,
94+
itemStyle: {
95+
...item.itemStyle,
96+
borderRadius: [0, 0, 4, 4],
97+
},
98+
};
99+
capRoundedNegative[stack] = true;
100+
}
101+
}
102+
});
103+
}

src/components/chart/statistics-chart.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { HomeAssistant } from "../../types";
3535
import { getPeriodicAxisLabelConfig } from "./axis-label";
3636
import type { CustomLegendOption } from "./ha-chart-base";
3737
import "./ha-chart-base";
38+
import { fillDataGapsAndRoundCaps } from "./round-caps";
3839

3940
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
4041
mean: "mean",
@@ -67,7 +68,11 @@ export class StatisticsChart extends LitElement {
6768
@property({ attribute: false })
6869
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
6970

70-
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
71+
@property({ attribute: false }) public chartType:
72+
| "line"
73+
| "line-stack"
74+
| "bar"
75+
| "bar-stack" = "line";
7176

7277
@property({ attribute: false }) public minYAxis?: number;
7378

@@ -326,7 +331,7 @@ export class StatisticsChart extends LitElement {
326331
},
327332
position: computeRTL(this.hass) ? "right" : "left",
328333
scale:
329-
this.chartType !== "bar" ||
334+
this.chartType.startsWith("line") ||
330335
this.logarithmicScale ||
331336
minYAxis !== undefined ||
332337
maxYAxis !== undefined,
@@ -386,6 +391,8 @@ export class StatisticsChart extends LitElement {
386391
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
387392

388393
let colorIndex = 0;
394+
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
395+
const chartStacked = this.chartType.endsWith("stack");
389396
const statisticsData = Object.entries(this.statisticsData);
390397
const totalDataSets: typeof this._chartData = [];
391398
const legendData: {
@@ -471,19 +478,17 @@ export class StatisticsChart extends LitElement {
471478
}
472479
statDataSets.forEach((d, i) => {
473480
if (
474-
this.chartType === "line" &&
481+
chartType === "line" &&
475482
prevEndTime &&
476483
prevValues &&
477484
prevEndTime.getTime() !== start.getTime()
478485
) {
479486
// if the end of the previous data doesn't match the start of the current data,
480487
// we have to draw a gap so add a value at the end time, and then an empty value.
481-
d.data!.push(
482-
this._transformDataValue([prevEndTime, ...prevValues[i]!])
483-
);
488+
d.data!.push([prevEndTime, ...prevValues[i]!]);
484489
d.data!.push([prevEndTime, null]);
485490
}
486-
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
491+
d.data!.push([start, ...dataValues[i]!]);
487492
});
488493
prevValues = dataValues;
489494
prevEndTime = end;
@@ -503,7 +508,8 @@ export class StatisticsChart extends LitElement {
503508
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
504509
const hasMin =
505510
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
506-
const drawBands = [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
511+
const drawBands =
512+
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
507513

508514
const hasState = this.statTypes.includes("state");
509515

@@ -535,8 +541,8 @@ export class StatisticsChart extends LitElement {
535541
const backgroundColor = band ? color + "3F" : color + "7F";
536542
const series: LineSeriesOption | BarSeriesOption = {
537543
id: `${statistic_id}-${type}`,
538-
type: this.chartType,
539-
smooth: this.chartType === "line" ? 0.4 : false,
544+
type: chartType,
545+
smooth: chartType === "line" ? 0.4 : false,
540546
cursor: "default",
541547
data: [],
542548
name: name
@@ -555,16 +561,23 @@ export class StatisticsChart extends LitElement {
555561
width: 1.5,
556562
},
557563
itemStyle:
558-
this.chartType === "bar"
564+
chartType === "bar"
559565
? {
560-
borderRadius: [4, 4, 0, 0],
561566
borderColor,
562567
borderWidth: 1.5,
563568
}
564569
: undefined,
565-
color: this.chartType === "bar" ? backgroundColor : borderColor,
570+
color: chartType === "bar" ? backgroundColor : borderColor,
566571
};
567-
if (band && this.chartType === "line") {
572+
if (chartStacked) {
573+
series.stack = `band-stacked`;
574+
series.stackStrategy = "samesign";
575+
if (chartType === "line") {
576+
(series as LineSeriesOption).areaStyle = {
577+
color: color + "3F",
578+
};
579+
}
580+
} else if (band && chartType === "line") {
568581
series.stack = `band-${statistic_id}`;
569582
series.stackStrategy = "all";
570583
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
@@ -621,7 +634,7 @@ export class StatisticsChart extends LitElement {
621634
}
622635
} else if (
623636
type === bandTop &&
624-
this.chartType === "line" &&
637+
chartType === "line" &&
625638
drawBands &&
626639
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
627640
) {
@@ -645,18 +658,17 @@ export class StatisticsChart extends LitElement {
645658
// For line charts, close out the last stat segment at prevEndTime
646659
const lastEndTime = prevEndTime;
647660
const lastValues = prevValues;
648-
if (this.chartType === "line" && lastEndTime && lastValues) {
661+
if (chartType === "line" && lastEndTime && lastValues) {
649662
statDataSets.forEach((d, i) => {
650-
d.data!.push(
651-
this._transformDataValue([lastEndTime, ...lastValues[i]!])
652-
);
663+
d.data!.push([lastEndTime, ...lastValues[i]!]);
653664
});
654665
}
655666

656667
// Show current state if required, and units match (or are unknown)
657668
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
658669
if (
659670
displayCurrentState &&
671+
!chartStacked &&
660672
(!this.unit || !statisticUnit || this.unit === statisticUnit)
661673
) {
662674
// Skip external statistics
@@ -677,7 +689,7 @@ export class StatisticsChart extends LitElement {
677689
const val: (number | null)[] = [];
678690
if (
679691
type === bandTop &&
680-
this.chartType === "line" &&
692+
chartType === "line" &&
681693
drawBands &&
682694
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
683695
) {
@@ -687,9 +699,7 @@ export class StatisticsChart extends LitElement {
687699
} else {
688700
val.push(currentValue);
689701
}
690-
statDataSets[i].data!.push(
691-
this._transformDataValue([now, ...val])
692-
);
702+
statDataSets[i].data!.push([now, ...val]);
693703
});
694704
}
695705
}
@@ -701,6 +711,13 @@ export class StatisticsChart extends LitElement {
701711
Array.prototype.push.apply(legendData, statLegendData);
702712
});
703713

714+
if (chartType === "bar") {
715+
fillDataGapsAndRoundCaps(
716+
totalDataSets as BarSeriesOption[],
717+
chartStacked
718+
);
719+
}
720+
704721
legendData.forEach(({ id, name, color, borderColor }) => {
705722
// Add an empty series for the legend
706723
totalDataSets.push({
@@ -710,7 +727,7 @@ export class StatisticsChart extends LitElement {
710727
itemStyle: {
711728
borderColor,
712729
},
713-
type: this.chartType,
730+
type: chartType,
714731
data: [],
715732
xAxisIndex: 1,
716733
});
@@ -728,13 +745,6 @@ export class StatisticsChart extends LitElement {
728745
this._statisticIds = statisticIds;
729746
}
730747

731-
private _transformDataValue(val: [Date, ...(number | null)[]]) {
732-
if (this.chartType === "bar" && val[1] && val[1] < 0) {
733-
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
734-
}
735-
return val;
736-
}
737-
738748
private _clampYAxis(value?: number | ((values: any) => number)) {
739749
if (this.logarithmicScale) {
740750
// log(0) is -Infinity, so we need to set a minimum value

src/panels/lovelace/cards/energy/common/energy-chart-options.ts

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
subDays,
1717
} from "date-fns";
1818
import type {
19-
BarSeriesOption,
2019
CallbackDataParams,
2120
LineSeriesOption,
2221
TopLevelFormatterParams,
@@ -38,6 +37,8 @@ import type { StatisticPeriod } from "../../../../../data/recorder";
3837
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
3938
import { getSuggestedPeriod } from "../../../../../data/energy";
4039

40+
export { fillDataGapsAndRoundCaps } from "../../../../../components/chart/round-caps";
41+
4142
/**
4243
* Energy chart data point tuple:
4344
* [0] displayX - bar position (midpoint for sub-daily periods, start otherwise)
@@ -280,72 +281,6 @@ function formatTooltip(
280281
return values.length > 0 ? `${title}${values.join("<br>")}${footer}` : "";
281282
}
282283

283-
export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
284-
const buckets = Array.from(
285-
new Set(
286-
datasets
287-
.map((dataset) =>
288-
dataset.data!.map((datapoint) => Number(datapoint![0]))
289-
)
290-
.flat()
291-
)
292-
).sort((a, b) => a - b);
293-
294-
// make sure all datasets have the same buckets
295-
// otherwise the chart will render incorrectly in some cases
296-
buckets.forEach((bucket, index) => {
297-
const capRounded = {};
298-
const capRoundedNegative = {};
299-
for (let i = datasets.length - 1; i >= 0; i--) {
300-
const dataPoint = datasets[i].data![index];
301-
const item: any =
302-
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
303-
? dataPoint
304-
: { value: dataPoint };
305-
const x = item.value?.[0];
306-
const stack = datasets[i].stack ?? "";
307-
if (x === undefined) {
308-
continue;
309-
}
310-
if (Number(x) !== bucket) {
311-
datasets[i].data?.splice(index, 0, {
312-
value: [bucket, 0],
313-
itemStyle: {
314-
borderWidth: 0,
315-
},
316-
});
317-
} else if (item.value?.[1] === 0) {
318-
// remove the border for zero values or it will be rendered
319-
datasets[i].data![index] = {
320-
...item,
321-
itemStyle: {
322-
...item.itemStyle,
323-
borderWidth: 0,
324-
},
325-
};
326-
} else if (!capRounded[stack] && item.value?.[1] > 0) {
327-
datasets[i].data![index] = {
328-
...item,
329-
itemStyle: {
330-
...item.itemStyle,
331-
borderRadius: [4, 4, 0, 0],
332-
},
333-
};
334-
capRounded[stack] = true;
335-
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
336-
datasets[i].data![index] = {
337-
...item,
338-
itemStyle: {
339-
...item.itemStyle,
340-
borderRadius: [0, 0, 4, 4],
341-
},
342-
};
343-
capRoundedNegative[stack] = true;
344-
}
345-
}
346-
});
347-
}
348-
349284
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
350285
const item =
351286
datapoint && typeof datapoint === "object" && "value" in datapoint

0 commit comments

Comments
 (0)