Migrate from chart.js to echarts (#23809)

This commit is contained in:
Petar Petrov 2025-01-23 17:51:48 +02:00 committed by GitHub
parent 62a9da2de2
commit 1f8cfdd0de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1875 additions and 2819 deletions

View File

@ -99,8 +99,6 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "2.3.1",
"chart.js": "4.4.7",
"chartjs-plugin-zoom": "2.2.0",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.40.0",
@ -110,6 +108,7 @@
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "5.6.0",
"element-internals-polyfill": "1.3.12",
"fuse.js": "7.0.0",
"google-timezones-json": "1.2.0",
@ -239,7 +238,8 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.14.0"
"globals": "15.14.0",
"tslib": "^2.3.0"
},
"packageManager": "yarn@4.6.0"
}

View File

@ -0,0 +1,62 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import { formatTime } from "../../common/datetime/format_time";
export function getLabelFormatter(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference = 0
) {
return (value: number | Date) => {
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
// show only date for the beginning of the day
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
}

View File

@ -1,269 +0,0 @@
import { _adapters } from "chart.js";
import {
startOfSecond,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfQuarter,
startOfYear,
addMilliseconds,
addSeconds,
addMinutes,
addHours,
addDays,
addWeeks,
addMonths,
addQuarters,
addYears,
differenceInMilliseconds,
differenceInSeconds,
differenceInMinutes,
differenceInHours,
differenceInDays,
differenceInWeeks,
differenceInMonths,
differenceInQuarters,
differenceInYears,
endOfSecond,
endOfMinute,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfQuarter,
endOfYear,
} from "date-fns";
import {
formatDate,
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayDay,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatDateTime,
formatDateTimeWithSeconds,
} from "../../common/datetime/format_date_time";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
const FORMATS = {
datetime: "datetime",
datetimeseconds: "datetimeseconds",
millisecond: "millisecond",
second: "second",
minute: "minute",
hour: "hour",
day: "day",
date: "date",
weekday: "weekday",
week: "week",
month: "month",
monthyear: "monthyear",
quarter: "quarter",
year: "year",
};
_adapters._date.override({
formats: () => FORMATS,
parse: (value: Date | number) => {
if (!(value instanceof Date)) {
return value;
}
return value.getTime();
},
format: function (time, fmt: keyof typeof FORMATS) {
switch (fmt) {
case "datetime":
return formatDateTime(
new Date(time),
this.options.locale,
this.options.config
);
case "datetimeseconds":
return formatDateTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "millisecond":
return formatTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "second":
return formatTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "minute":
return formatTime(
new Date(time),
this.options.locale,
this.options.config
);
case "hour":
return formatTime(
new Date(time),
this.options.locale,
this.options.config
);
case "weekday":
return formatDateWeekdayDay(
new Date(time),
this.options.locale,
this.options.config
);
case "date":
return formatDate(
new Date(time),
this.options.locale,
this.options.config
);
case "day":
return formatDateVeryShort(
new Date(time),
this.options.locale,
this.options.config
);
case "week":
return formatDateVeryShort(
new Date(time),
this.options.locale,
this.options.config
);
case "month":
return formatDateMonth(
new Date(time),
this.options.locale,
this.options.config
);
case "monthyear":
return formatDateMonthYear(
new Date(time),
this.options.locale,
this.options.config
);
case "quarter":
return formatDate(
new Date(time),
this.options.locale,
this.options.config
);
case "year":
return formatDateYear(
new Date(time),
this.options.locale,
this.options.config
);
default:
return "";
}
},
// @ts-ignore
add: (time, amount, unit) => {
switch (unit) {
case "millisecond":
return addMilliseconds(time, amount);
case "second":
return addSeconds(time, amount);
case "minute":
return addMinutes(time, amount);
case "hour":
return addHours(time, amount);
case "day":
return addDays(time, amount);
case "week":
return addWeeks(time, amount);
case "month":
return addMonths(time, amount);
case "quarter":
return addQuarters(time, amount);
case "year":
return addYears(time, amount);
default:
return time;
}
},
diff: (max, min, unit) => {
switch (unit) {
case "millisecond":
return differenceInMilliseconds(max, min);
case "second":
return differenceInSeconds(max, min);
case "minute":
return differenceInMinutes(max, min);
case "hour":
return differenceInHours(max, min);
case "day":
return differenceInDays(max, min);
case "week":
return differenceInWeeks(max, min);
case "month":
return differenceInMonths(max, min);
case "quarter":
return differenceInQuarters(max, min);
case "year":
return differenceInYears(max, min);
default:
return 0;
}
},
// @ts-ignore
startOf: (time, unit, weekday) => {
switch (unit) {
case "second":
return startOfSecond(time);
case "minute":
return startOfMinute(time);
case "hour":
return startOfHour(time);
case "day":
return startOfDay(time);
case "week":
return startOfWeek(time);
case "isoWeek":
return startOfWeek(time, {
weekStartsOn: +weekday! as 0 | 1 | 2 | 3 | 4 | 5 | 6,
});
case "month":
return startOfMonth(time);
case "quarter":
return startOfQuarter(time);
case "year":
return startOfYear(time);
default:
return time;
}
},
// @ts-ignore
endOf: (time, unit) => {
switch (unit) {
case "second":
return endOfSecond(time);
case "minute":
return endOfMinute(time);
case "hour":
return endOfHour(time);
case "day":
return endOfDay(time);
case "week":
return endOfWeek(time);
case "month":
return endOfMonth(time);
case "quarter":
return endOfQuarter(time);
case "year":
return endOfYear(time);
default:
return time;
}
},
});

View File

@ -1,6 +0,0 @@
import type { ChartEvent } from "chart.js";
export const clickIsTouch = (event: ChartEvent): boolean =>
!(event.native instanceof MouseEvent) ||
(event.native instanceof PointerEvent &&
event.native.pointerType !== "mouse");

View File

@ -1,266 +1,150 @@
import type {
Chart,
ChartType,
ChartData,
ChartOptions,
TooltipModel,
UpdateMode,
} from "chart.js";
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { fireEvent } from "../../common/dom/fire_event";
import { clamp } from "../../common/number/clamp";
import type { HomeAssistant } from "../../types";
import { debounce } from "../../common/util/debounce";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
import type { ECOption } from "../../resources/echarts";
import { listenMediaQuery } from "../../common/dom/media_query";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
interface Tooltip
extends Omit<TooltipModel<any>, "tooltipPosition" | "hasValue" | "getProps"> {
top: string;
left: string;
}
export interface ChartDatasetExtra {
show_legend?: boolean;
legend_label?: string;
}
@customElement("ha-chart-base")
export class HaChartBase extends LitElement {
public chart?: Chart;
public chart?: EChartsType;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "chart-type", reflect: true })
public chartType: ChartType = "line";
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public data: ChartData = { datasets: [] };
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public extraData?: ChartDatasetExtra[];
@property({ attribute: false }) public options?: ChartOptions;
@property({ attribute: false }) public plugins?: any[];
@property({ type: Number }) public height?: number;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ type: String }) public height?: string;
@property({ attribute: "external-hidden", type: Boolean })
public externalHidden = false;
@state() private _legendHeight?: number;
@state() private _tooltip?: Tooltip;
@state() private _hiddenDatasets = new Set<number>();
@state() private _showZoomHint = false;
@state() private _isZoomed = false;
private _paddingUpdateCount = 0;
private _modifierPressed = false;
private _paddingUpdateLock = false;
private _isTouchDevice = "ontouchstart" in window;
private _paddingYAxisInternal = 0;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => this.chart?.resize(),
});
private _datasetOrder: number[] = [];
private _loading = false;
private _reducedMotion = false;
private _listeners: (() => void)[] = [];
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("scroll", this._handleScroll, true);
this._releaseCanvas();
while (this._listeners.length) {
this._listeners.pop()!();
}
this.chart?.dispose();
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("scroll", this._handleScroll, true);
if (this.hasUpdated) {
this._releaseCanvas();
this._setupChart();
}
}
public updateChart = (mode?: UpdateMode): void => {
this.chart?.update(mode);
};
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion });
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
const handleKeyUp = (ev: KeyboardEvent) => {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
}
protected firstUpdated() {
this._setupChart();
this.data.datasets.forEach((dataset, index) => {
if (dataset.hidden) {
this._hiddenDatasets.add(index);
}
});
}
public shouldUpdate(changedProps: PropertyValues): boolean {
if (
this._paddingUpdateLock &&
changedProps.size === 1 &&
changedProps.has("paddingYAxis")
) {
return false;
}
return true;
}
private _debouncedClearUpdates = debounce(
() => {
this._paddingUpdateCount = 0;
},
2000,
false
);
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this._paddingUpdateLock) {
this._paddingYAxisInternal = this.paddingYAxis;
if (changedProps.size === 1 && changedProps.has("paddingYAxis")) {
this._paddingUpdateCount++;
if (this._paddingUpdateCount > 300) {
this._paddingUpdateLock = true;
// eslint-disable-next-line
console.error(
"Detected excessive chart padding updates, possibly an infinite loop. Disabling axis padding."
);
} else {
this._debouncedClearUpdates();
}
}
}
// put the legend labels in sorted order if provided
if (changedProps.has("data")) {
this._datasetOrder = this.data.datasets.map((_, index) => index);
if (this.data?.datasets.some((dataset) => dataset.order)) {
this._datasetOrder.sort(
(a, b) =>
(this.data.datasets[a].order || 0) -
(this.data.datasets[b].order || 0)
);
}
if (this.externalHidden) {
this._hiddenDatasets = new Set();
if (this.data?.datasets) {
this.data.datasets.forEach((dataset, index) => {
if (dataset.hidden) {
this._hiddenDatasets.add(index);
}
});
}
}
}
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("plugins") || changedProps.has("chartType")) {
this._releaseCanvas();
this._setupChart();
return;
}
if (changedProps.has("data")) {
if (this._hiddenDatasets.size && !this.externalHidden) {
this.data.datasets.forEach((dataset, index) => {
dataset.hidden = this._hiddenDatasets.has(index);
});
}
this.chart.data = this.data;
this.chart.setOption(
{ series: this.data },
{ lazyUpdate: true, replaceMerge: ["series"] }
);
}
if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) {
// this resets the chart zoom because min/max scales changed
// so we only do it if the user is not zooming or panning
this.chart.options = this._createOptions();
}
this.chart.update("none");
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("data") || changedProperties.has("options")) {
if (this.options?.plugins?.legend?.display) {
this._legendHeight =
this.renderRoot.querySelector(".chart-legend")?.clientHeight;
} else {
this._legendHeight = 0;
}
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
replaceMerge: [
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
});
}
}
protected render() {
return html`
${this.options?.plugins?.legend?.display === true
? html`<div class="chart-legend">
<ul>
${this._datasetOrder.map((index) => {
const dataset = this.data.datasets[index];
return this.extraData?.[index]?.show_legend === false
? nothing
: html`<li
.datasetIndex=${index}
@click=${this._legendClick}
class=${classMap({
hidden: this._hiddenDatasets.has(index),
})}
.title=${this.extraData?.[index]?.legend_label ??
dataset.label}
>
<div
class="bullet"
style=${styleMap({
backgroundColor: dataset.backgroundColor as string,
borderColor: dataset.borderColor as string,
})}
></div>
<div class="label">
${this.extraData?.[index]?.legend_label ??
dataset.label}
</div>
</li>`;
})}
</ul>
</div>`
: ""}
<div
class="chart-container"
style=${styleMap({
height: `${this.height ?? this._getDefaultHeight()}px`,
"padding-left": `${this._paddingYAxisInternal}px`,
"padding-right": 0,
"padding-inline-start": `${this._paddingYAxisInternal}px`,
"padding-inline-end": 0,
height: this.height ?? `${this._getDefaultHeight()}px`,
})}
@wheel=${this._handleChartScroll}
@wheel=${this._handleWheel}
>
<canvas
class=${classMap({
"not-zoomed": !this._isZoomed,
})}
></canvas>
<div
class="zoom-hint ${classMap({
visible: this._showZoomHint,
})}"
>
<div>
${isMac
? this.hass.localize("ui.components.history_charts.zoom_hint_mac")
: this.hass.localize("ui.components.history_charts.zoom_hint")}
</div>
</div>
${this._isZoomed && this.chartType !== "timeline"
<div class="chart"></div>
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@ -270,261 +154,145 @@ export class HaChartBase extends LitElement {
)}
></ha-icon-button>`
: nothing}
${this._tooltip
? html`<div
class="chart-tooltip ${classMap({
[this._tooltip.yAlign]: true,
})}"
style=${styleMap({
top: this._tooltip.top,
left: this._tooltip.left,
})}
>
<div class="title">${this._tooltip.title}</div>
${this._tooltip.beforeBody
? html`<div class="before-body">
${this._tooltip.beforeBody}
</div>`
: ""}
<div>
<ul>
${this._tooltip.body.map(
(item, i) =>
html`<li>
<div
class="bullet"
style=${styleMap({
backgroundColor: this._tooltip!.labelColors[i]
.backgroundColor as string,
borderColor: this._tooltip!.labelColors[i]
.borderColor as string,
})}
></div>
${item.lines.join("\n")}
</li>`
)}
</ul>
</div>
${this._tooltip.footer.length
? html`<div class="footer">
${this._tooltip.footer.map((item) => html`${item}<br />`)}
</div>`
: ""}
</div>`
: ""}
</div>
`;
}
private _loading = false;
private async _setupChart() {
if (this._loading) return;
const ctx: CanvasRenderingContext2D = this.renderRoot
.querySelector("canvas")!
.getContext("2d")!;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
this._loading = true;
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const ChartConstructor = (await import("../../resources/chartjs")).Chart;
const echarts = (await import("../../resources/echarts")).default;
const computedStyles = getComputedStyle(this);
ChartConstructor.defaults.borderColor =
computedStyles.getPropertyValue("--divider-color");
ChartConstructor.defaults.color = computedStyles.getPropertyValue(
"--secondary-text-color"
);
ChartConstructor.defaults.font.family =
computedStyles.getPropertyValue("--mdc-typography-body1-font-family") ||
computedStyles.getPropertyValue("--mdc-typography-font-family") ||
"Roboto, Noto, sans-serif";
this.chart = new ChartConstructor(ctx, {
type: this.chartType,
data: this.data,
options: this._createOptions(),
plugins: this._createPlugins(),
this.chart = echarts.init(container);
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;
});
this.chart.setOption({ ...this._createOptions(), series: this.data });
} finally {
this._loading = false;
}
}
private _createOptions(): ChartOptions {
const modifierKey = isMac ? "meta" : "ctrl";
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
const xAxis = (this.options?.xAxis?.[0] ??
this.options?.xAxis) as XAXisOption;
const yAxis = (this.options?.yAxis?.[0] ??
this.options?.yAxis) as YAXisOption;
if (xAxis.type === "value" && yAxis.type === "category") {
// vertical data zoom doesn't work well in this case and horizontal is pointless
return undefined;
}
return {
maintainAspectRatio: false,
animation: {
duration: 500,
},
...this.options,
plugins: {
...this.options?.plugins,
tooltip: {
...this.options?.plugins?.tooltip,
enabled: false,
external: (context) => this._handleTooltip(context),
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
zoom: {
...this.options?.plugins?.zoom,
pan: {
enabled: true,
},
zoom: {
pinch: {
enabled: true,
},
drag: {
enabled: true,
modifierKey,
threshold: 2,
},
wheel: {
enabled: true,
modifierKey,
speed: 0.05,
},
mode:
this.chartType !== "timeline" &&
(this.options?.scales?.y as any)?.type === "category"
? "y"
: "x",
onZoomComplete: () => {
const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
if (this._isZoomed && !isZoomed) {
setTimeout(() => {
// make sure the scales are properly reset after full zoom out
// they get bugged when zooming in/out multiple times and panning
this.chart?.resetZoom();
});
}
this._isZoomed = isZoomed;
},
},
limits: {
x: {
min: "original",
max: (this.options?.scales?.x as any)?.max ?? "original",
},
y: {
min: "original",
max: "original",
},
},
},
},
id: "dataZoom",
type: "inside",
orient: "horizontal",
filterMode: "none",
moveOnMouseMove: this._isZoomed,
preventDefaultMouseMove: this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
};
}
private _createPlugins() {
return [
...(this.plugins || []),
{
id: "resizeHook",
resize: (chart: Chart) => {
if (!this.height) {
// lock the height
// this removes empty space below the chart
this.height = chart.height;
}
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
private _createOptions(): ECOption {
const darkMode = this.hass.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 = {
animation: !this._reducedMotion,
darkMode,
aria: {
show: true,
},
];
dataZoom: this._getDataZoomConfig(),
...this.options,
xAxis: xAxis.map((axis) =>
axis
? {
...axis,
splitLine: axis.splitLine
? {
...axis.splitLine,
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(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
});
options.tooltip = tooltips;
}
return options;
}
private _getDefaultHeight() {
return this.clientWidth / 2;
}
private _handleChartScroll(ev: MouseEvent) {
const modifier = isMac ? "metaKey" : "ctrlKey";
this._tooltip = undefined;
if (!ev[modifier] && !this._showZoomHint) {
this._showZoomHint = true;
setTimeout(() => {
this._showZoomHint = false;
}, 1000);
}
}
private _legendClick(ev) {
if (!this.chart) {
return;
}
const index = ev.currentTarget.datasetIndex;
if (this.chart.isDatasetVisible(index)) {
this.chart.setDatasetVisibility(index, false);
this._hiddenDatasets.add(index);
if (this.externalHidden) {
fireEvent(this, "dataset-hidden", {
index,
});
}
} else {
this.chart.setDatasetVisibility(index, true);
this._hiddenDatasets.delete(index);
if (this.externalHidden) {
fireEvent(this, "dataset-unhidden", {
index,
});
}
}
this.chart.update("none");
this.requestUpdate("_hiddenDatasets");
}
private _handleTooltip(context: {
chart: Chart;
tooltip: TooltipModel<any>;
}) {
if (context.tooltip.opacity === 0) {
this._tooltip = undefined;
return;
}
const boundingBox = this.getBoundingClientRect();
this._tooltip = {
...context.tooltip,
top:
boundingBox.y +
(this._legendHeight || 0) +
context.tooltip.caretY +
12 +
"px",
left:
clamp(
boundingBox.x + context.tooltip.caretX,
boundingBox.x + 100,
boundingBox.x + boundingBox.width - 100
) -
100 +
"px",
};
}
private _releaseCanvas() {
// release the canvas memory to prevent
// safari from running out of memory.
if (this.chart) {
this.chart.destroy();
}
return Math.max(this.clientWidth / 2, 400);
}
private _handleZoomReset() {
this.chart?.resetZoom();
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
private _handleScroll = () => {
this._tooltip = undefined;
};
private _handleWheel(e: WheelEvent) {
// if the window is not focused, we don't receive the keydown events but scroll still works
if (!this.options?.dataZoom) {
const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (modifierPressed) {
e.preventDefault();
}
if (modifierPressed !== this._modifierPressed) {
this._modifierPressed = modifierPressed;
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
}
static styles = css`
:host {
@ -533,124 +301,11 @@ export class HaChartBase extends LitElement {
}
.chart-container {
position: relative;
}
canvas {
max-height: var(--chart-max-height, 400px);
}
canvas.not-zoomed {
/* allow scrolling if the chart is not zoomed */
touch-action: pan-y !important;
}
.chart-legend {
text-align: center;
}
.chart-legend li {
cursor: pointer;
display: inline-grid;
grid-auto-flow: column;
padding: 0 8px;
box-sizing: border-box;
align-items: center;
color: var(--secondary-text-color);
}
.chart-legend .hidden {
text-decoration: line-through;
}
.chart-legend .label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.chart-legend .bullet,
.chart-tooltip .bullet {
border-width: 1px;
border-style: solid;
border-radius: 50%;
display: inline-block;
height: 16px;
margin-right: 6px;
width: 16px;
flex-shrink: 0;
box-sizing: border-box;
margin-inline-end: 6px;
margin-inline-start: initial;
direction: var(--direction);
}
.chart-tooltip .bullet {
align-self: baseline;
}
.chart-tooltip {
padding: 8px;
font-size: 90%;
position: fixed;
background: rgba(80, 80, 80, 0.9);
color: white;
border-radius: 4px;
pointer-events: none;
z-index: 1;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
width: 200px;
box-sizing: border-box;
direction: var(--direction);
}
.chart-legend ul,
.chart-tooltip ul {
display: inline-block;
padding: 0 0px;
margin: 8px 0 0 0;
.chart {
width: 100%;
}
.chart-tooltip ul {
margin: 0 4px;
}
.chart-tooltip li {
display: flex;
white-space: pre-line;
word-break: break-word;
align-items: center;
line-height: 16px;
padding: 4px 0;
}
.chart-tooltip .title {
text-align: center;
font-weight: 500;
word-break: break-word;
direction: ltr;
}
.chart-tooltip .footer {
font-weight: 500;
}
.chart-tooltip .before-body {
text-align: center;
font-weight: 300;
word-break: break-all;
}
.zoom-hint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.zoom-hint.visible {
opacity: 1;
}
.zoom-hint > div {
color: white;
font-size: 1.5em;
font-weight: 500;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3);
height: 100%;
}
.zoom-reset {
position: absolute;
@ -670,7 +325,7 @@ declare global {
"ha-chart-base": HaChartBase;
}
interface HASSDomEvents {
"dataset-hidden": { index: number };
"dataset-unhidden": { index: number };
"dataset-hidden": { name: string };
"dataset-unhidden": { name: string };
}
}

View File

@ -3,6 +3,7 @@ import { LitElement, html, css, svg, nothing } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
import { measureTextWidth } from "../../util/text";
export interface Node {
id: string;
@ -68,15 +69,12 @@ export class HaSankeyChart extends LitElement {
private _statePerPixel = 0;
private _textMeasureCanvas?: HTMLCanvasElement;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
disconnectedCallback() {
super.disconnectedCallback();
this._textMeasureCanvas = undefined;
}
willUpdate() {
@ -477,7 +475,7 @@ export class HaSankeyChart extends LitElement {
(node) =>
NODE_WIDTH +
TEXT_PADDING +
(node.label ? this._getTextWidth(node.label) : 0)
(node.label ? measureTextWidth(node.label, FONT_SIZE) : 0)
)
)
: 0;
@ -492,18 +490,6 @@ export class HaSankeyChart extends LitElement {
: fullSize / nodesPerSection.length;
}
private _getTextWidth(text: string): number {
if (!this._textMeasureCanvas) {
this._textMeasureCanvas = document.createElement("canvas");
}
const context = this._textMeasureCanvas.getContext("2d");
if (!context) return 0;
// Match the font style from CSS
context.font = `${FONT_SIZE}px sans-serif`;
return context.measureText(text).width;
}
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
// reduce the label font size so the longest word fits on one line
const longestWord = label
@ -513,7 +499,7 @@ export class HaSankeyChart extends LitElement {
longest.length > current.length ? longest : current,
""
);
const wordWidth = this._getTextWidth(longestWord);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
}

View File

@ -1,19 +1,26 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
import type { ECOption } from "../../resources/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@ -54,15 +61,17 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@state() private _chartData?: ChartData<"line">;
@property({ type: String }) public height?: string;
@state() private _chartData: LineSeriesOption[] = [];
@state() private _entityIds: string[] = [];
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ChartOptions;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@state() private _yWidth = 25;
private _chartTime: Date = new Date();
@ -72,171 +81,54 @@ export class StateHistoryChartLine extends LitElement {
.hass=${this.hass}
.data=${this._chartData}
.options=${this._chartOptions}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="line"
.height=${this.height}
style=${styleMap({ height: this.height })}
></ha-chart-base>
`;
}
private _renderTooltip(params) {
return params
.map((param, index: number) => {
let value = `${formatNumber(
param.value[1] as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[param.seriesIndex]]
)
)} ${this.unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
public willUpdate(changedProps: PropertyValues) {
if (
!this.hasUpdated ||
changedProps.has("showNames") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("unit") ||
changedProps.has("logarithmicScale") ||
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData")
) {
this._chartOptions = {
parsing: false,
interaction: {
mode: "nearest",
axis: "xy",
},
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
min: this.startTime,
max: this.endTime,
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
suggestedMin: this.fitYData ? this.minYAxis : null,
suggestedMax: this.fitYData ? this.maxYAxis : null,
min: this.fitYData ? null : this.minYAxis,
max: this.fitYData ? null : this.maxYAxis,
ticks: {
maxTicksLimit: 7,
},
title: {
display: true,
text: this.unit,
},
afterUpdate: (y) => {
if (this._yWidth !== Math.floor(y.width)) {
this._yWidth = Math.floor(y.width);
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
},
position: computeRTL(this.hass) ? "right" : "left",
type: this.logarithmicScale ? "logarithmic" : "linear",
},
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
let label = `${context.dataset.label}: ${formatNumber(
context.parsed.y,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[context.datasetIndex]]
)
)} ${this.unit}`;
const dataIndex =
this._datasetToDataIndex[context.datasetIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
context.parsed.x < data.states[0].last_changed
? `\n${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `\n${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
label += source;
}
return label;
},
},
},
filler: {
propagate: true,
},
legend: {
display: this.showNames,
labels: {
usePointStyle: true,
},
},
},
elements: {
line: {
tension: 0.1,
borderWidth: 1.5,
},
point: {
hitRadius: 50,
},
},
segment: {
borderColor: (context) => {
// render stat data with a slightly transparent line
const dataIndex = this._datasetToDataIndex[context.datasetIndex];
const data = this.data[dataIndex];
return data.statistics &&
data.statistics.length > 0 &&
(data.states.length === 0 ||
context.p0.parsed.x < data.states[0].last_changed)
? this._chartData!.datasets[dataIndex].borderColor + "7F"
: undefined;
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
}
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
);
if (points.length) {
const firstPoint = points[0];
fireEvent(this, "hass-more-info", {
entityId: this._entityIds[firstPoint.datasetIndex],
});
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
},
};
}
if (
changedProps.has("data") ||
changedProps.has("startTime") ||
@ -248,13 +140,130 @@ export class StateHistoryChartLine extends LitElement {
// so the X axis grows even if there is no new data
this._generateData();
}
if (
!this.hasUpdated ||
changedProps.has("showNames") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("unit") ||
changedProps.has("logarithmicScale") ||
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("_chartData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
const splitLineStyle = this.hass.themes?.darkMode
? { opacity: 0.15 }
: {};
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this.fitYData ? this.minYAxis : undefined,
max: this.fitYData ? this.maxYAxis : undefined,
position: rtl ? "right" : "left",
scale: true,
nameGap: 3,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
axisLabel: {
margin: 5,
formatter: (value: number) => {
const label = formatNumber(value, this.hass.locale);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
},
} as YAXisOption,
legend: {
show: this.showNames,
icon: "circle",
padding: [20, 0],
},
grid: {
...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0
left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth),
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30,
},
visualMap: this._chartData
.map((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return false;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
return {
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
};
})
.filter(Boolean) as VisualMapComponentOption[],
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip.bind(this),
},
};
}
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const entityStates = this.data;
const datasets: ChartDataset<"line">[] = [];
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
if (entityStates.length === 0) {
@ -270,7 +279,7 @@ export class StateHistoryChartLine extends LitElement {
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: ChartDataset<"line">[] = [];
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
@ -287,9 +296,9 @@ export class StateHistoryChartLine extends LitElement {
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data.push({ x: timestamp.getTime(), y: prevValues[i] });
d.data!.push([timestamp, prevValues[i]]);
}
d.data.push({ x: timestamp.getTime(), y: datavalues[i] });
d.data!.push([timestamp, datavalues[i]]);
});
prevValues = datavalues;
};
@ -300,13 +309,25 @@ export class StateHistoryChartLine extends LitElement {
colorIndex++;
}
data.push({
label: nameY,
fill: fill ? "origin" : false,
borderColor: color,
backgroundColor: color + "7F",
stepped: "before",
pointRadius: 0,
id: nameY,
data: [],
type: "line",
name: nameY,
color,
symbol: "circle",
step: "end",
symbolSize: 1,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: color + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
@ -575,9 +596,7 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._chartData = {
datasets,
};
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
}

View File

@ -1,19 +1,27 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { getRelativePosition } from "chart.js/helpers";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { numberFormatToLocale } from "../../common/number/format_number";
import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { TimeLineData } from "./timeline-chart/const";
import { computeTimelineColor } from "./timeline-chart/timeline-color";
import { clickIsTouch } from "./click_is_touch";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts";
import echarts from "../../resources/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@ -44,9 +52,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false, type: Number }) public chartIndex?;
@state() private _chartData?: ChartData<"timeline">;
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ChartOptions<"timeline">;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@ -56,15 +64,95 @@ export class StateHistoryChartTimeline extends LitElement {
return html`
<ha-chart-base
.hass=${this.hass}
.data=${this._chartData}
.options=${this._chartOptions}
.height=${this.data.length * 30 + 30}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="timeline"
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData}
></ha-chart-base>
`;
}
private _renderItem: CustomSeriesRenderItem = (params, api) => {
const categoryIndex = api.value(0);
const start = api.coord([api.value(1), categoryIndex]);
const end = api.coord([api.value(2), categoryIndex]);
const height = 20;
const coordSys = params.coordSys as any;
const rectShape = echarts.graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height: height,
},
{
x: coordSys.x,
y: coordSys.y,
width: coordSys.width,
height: coordSys.height,
}
);
if (!rectShape) return null;
const rect = {
type: "rect" as const,
transition: "shape" as const,
shape: rectShape,
style: {
fill: api.value(4) as string,
},
};
const text = api.value(3) as string;
const textWidth = measureTextWidth(text, 12);
const LABEL_PADDING = 4;
if (textWidth < rectShape.width - LABEL_PADDING * 2) {
return {
type: "group",
children: [
rect,
{
type: "text",
style: {
...rectShape,
x: rectShape.x + LABEL_PADDING,
text,
fill: api.value(5) as string,
fontSize: 12,
lineHeight: rectShape.height,
},
},
],
};
}
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker } = Array.isArray(params)
? params[0]
: params;
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const lines = [
marker + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._createOptions();
@ -85,7 +173,8 @@ export class StateHistoryChartTimeline extends LitElement {
if (
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames")
changedProps.has("showNames") ||
changedProps.has("paddingYAxis")
) {
this._createOptions();
}
@ -93,144 +182,68 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const labelWidth = showNames
? Math.max(narrow ? 70 : 170, this.paddingYAxis)
: 0;
const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = {
maintainAspectRatio: false,
parsing: false,
scales: {
x: {
type: "time",
position: "bottom",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
min: this.startTime,
suggestedMax: this.endTime,
ticks: {
autoSkip: true,
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
grid: {
offset: false,
},
time: {
tooltipFormat: "datetimeseconds",
},
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "category",
inverse: true,
position: rtl ? "right" : "left",
axisTick: {
show: false,
},
y: {
type: "category",
barThickness: 20,
offset: true,
grid: {
display: false,
drawBorder: false,
drawTicks: false,
},
ticks: {
display: this.chunked || this.showNames,
},
afterSetDimensions: (y) => {
y.maxWidth = y.chart.width * 0.18;
},
afterFit: (scaleInstance) => {
if (this.chunked) {
// ensure all the chart labels are the same width
scaleInstance.width = narrow ? 105 : 185;
}
},
afterUpdate: (y) => {
const yWidth = this.showNames
? (y.width ?? 0)
: computeRTL(this.hass)
? 0
: (y.left ?? 0);
if (
this._yWidth !== Math.floor(yWidth) &&
y.ticks.length === this.data.length
) {
this._yWidth = Math.floor(yWidth);
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: 5,
// @ts-ignore this is a valid option
formatter: (label: string) => {
const width = Math.min(measureTextWidth(label, 12) + 5, labelWidth);
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
position: computeRTL(this.hass) ? "right" : "left",
hideOverlap: true,
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
title: (context) =>
context![0].chart!.data!.labels![
context[0].datasetIndex
] as string,
beforeBody: (context) => context[0].dataset.label || "",
label: (item) => {
const d = item.dataset.data[item.dataIndex] as TimeLineData;
const durationInMs = d.end.getTime() - d.start.getTime();
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
return [
d.label || "",
formatDateTimeWithSeconds(
d.start,
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
d.end,
this.hass.locale,
this.hass.config
),
formattedDuration,
];
},
labelColor: (item) => ({
borderColor: (item.dataset.data[item.dataIndex] as TimeLineData)
.color!,
backgroundColor: (
item.dataset.data[item.dataIndex] as TimeLineData
).color!,
}),
},
},
filler: {
propagate: true,
},
grid: {
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
}
const chart = e.chart;
const canvasPosition = getRelativePosition(e, chart);
const index = Math.abs(
chart.scales.y.getValueForPixel(canvasPosition.y)
);
fireEvent(this, "hass-more-info", {
// @ts-ignore
entityId: this._chartData?.datasets[index]?.label,
});
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
tooltip: {
appendTo: document.body,
formatter: this._renderTooltip,
},
};
}
@ -246,8 +259,7 @@ export class StateHistoryChartTimeline extends LitElement {
this._chartTime = new Date();
const startTime = this.startTime;
const endTime = this.endTime;
const labels: string[] = [];
const datasets: ChartDataset<"timeline">[] = [];
const datasets: CustomSeriesOption[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
@ -258,7 +270,7 @@ export class StateHistoryChartTimeline extends LitElement {
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const dataRow: TimeLineData[] = [];
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
@ -277,15 +289,23 @@ export class StateHistoryChartTimeline extends LitElement {
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
start: prevLastChanged,
end: newLastChanged,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
value: [
entityDisplay,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
@ -295,28 +315,40 @@ export class StateHistoryChartTimeline extends LitElement {
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
start: prevLastChanged,
end: endTime,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
value: [
entityDisplay,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
data: dataRow,
label: stateInfo.entity_id,
name: entityDisplay,
dimensions: ["index", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
});
labels.push(entityDisplay);
});
this._chartData = {
labels: labels,
datasets: datasets,
};
this._chartData = datasets;
}
static styles = css`

View File

@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@ -69,6 +70,8 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@property({ type: String }) public height?: string;
private _computedStartTime!: Date;
private _computedEndTime!: Date;
@ -133,7 +136,10 @@ export class StateHistoryCharts extends LitElement {
return html``;
}
if (!Array.isArray(item)) {
return html`<div class="entry-container">
return html`<div
class="entry-container"
style=${styleMap({ height: this.height })}
>
<state-history-chart-line
.hass=${this.hass}
.unit=${item.unit}
@ -151,6 +157,7 @@ export class StateHistoryCharts extends LitElement {
.maxYAxis=${this.maxYAxis}
.fitYData=${this.fitYData}
@y-width-changed=${this._yWidthChanged}
.height=${this.height}
></state-history-chart-line>
</div> `;
}

View File

@ -1,21 +1,15 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ChartType,
} from "chart.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
BarSeriesOption,
LineSeriesOption,
} from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import type {
Statistics,
StatisticsMetaData,
@ -25,13 +19,18 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import type { ChartDatasetExtra } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@ -62,7 +61,7 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false, type: Array })
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType: ChartType = "line";
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
@property({ attribute: false, type: Number }) public minYAxis?: number;
@ -84,15 +83,18 @@ export class StatisticsChart extends LitElement {
@property() public period?: string;
@state() private _chartData: ChartData = { datasets: [] };
@property({ attribute: "days-to-show", type: Number })
public daysToShow?: number;
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
@property({ type: String }) public height?: string;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: string[] = [];
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ChartOptions;
@state() private _hiddenStats = new Set<string>();
@state() private _chartOptions?: ECOption;
private _computedStyle?: CSSStyleDeclaration;
@ -101,8 +103,13 @@ export class StatisticsChart extends LitElement {
}
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("legendMode")) {
this._hiddenStats.clear();
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend")
) {
this._generateData();
}
if (
!this.hasUpdated ||
@ -117,15 +124,6 @@ export class StatisticsChart extends LitElement {
) {
this._createOptions();
}
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
}
}
public firstUpdated() {
@ -157,146 +155,105 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
external-hidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
.options=${this._chartOptions}
.chartType=${this.chartType}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
.height=${this.height}
style=${styleMap({ height: this.height })}
></ha-chart-base>
`;
}
private _datasetHidden(ev) {
ev.stopPropagation();
this._hiddenStats.add(this._statisticIds[ev.detail.index]);
this.requestUpdate("_hiddenStats");
}
private _renderTooltip(params: any) {
return params
.map((param, index: number) => {
const value = `${formatNumber(
// max series has 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
private _datasetUnhidden(ev) {
ev.stopPropagation();
this._hiddenStats.delete(this._statisticIds[ev.detail.index]);
this.requestUpdate("_hiddenStats");
}
private _createOptions(unit?: string) {
this._chartOptions = {
parsing: false,
interaction: {
mode: "nearest",
axis: "x",
},
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
ticks: {
source: this.chartType === "bar" ? "data" : undefined,
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetime",
unit:
this.chartType === "bar" &&
this.period &&
["hour", "day", "week", "month"].includes(this.period)
? this.period
: undefined,
},
},
y: {
beginAtZero: this.chartType === "bar",
ticks: {
maxTicksLimit: 7,
},
title: {
display: unit || this.unit,
text: unit || this.unit,
},
type: this.logarithmicScale ? "logarithmic" : "linear",
min: this.fitYData ? null : this.minYAxis,
max: this.fitYData ? null : this.maxYAxis,
},
},
plugins: {
tooltip: {
callbacks: {
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[context.datasetIndex]]
)
)} ${
// @ts-ignore
context.dataset.unit || ""
}`,
},
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1;
this._chartOptions = {
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
filler: {
propagate: true,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
legend: {
display: !this.hideLegend,
labels: {
usePointStyle: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore
scale: this.chartType !== "bar",
min: this.fitYData ? undefined : this.minYAxis,
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
},
elements: {
line: {
tension: 0.4,
cubicInterpolationMode: "monotone",
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 50,
},
legend: {
show: !this.hideLegend,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
}
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
);
if (points.length) {
const firstPoint = points[0];
const statisticId = this._statisticIds[firstPoint.datasetIndex];
if (!isExternalStatistic(statisticId)) {
fireEvent(this, "hass-more-info", { entityId: statisticId });
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
}
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 20,
right: 1,
bottom: 0,
containLabel: true,
},
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip.bind(this),
},
// scales: {
// x: {
// ticks: {
// source: this.chartType === "bar" ? "data" : undefined,
// },
// },
// },
};
}
@ -325,8 +282,8 @@ export class StatisticsChart extends LitElement {
let colorIndex = 0;
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: ChartDataset<"line">[] = [];
const totalDatasetExtras: ChartDatasetExtra[] = [];
const totalDataSets: LineSeriesOption[] = [];
const legendData: string[] = [];
const statisticIds: string[] = [];
let endTime: Date;
@ -372,19 +329,19 @@ export class StatisticsChart extends LitElement {
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[] | null = null;
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: ChartDataset<"line">[] = [];
const statDatasetExtras: ChartDatasetExtra[] = [];
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: string[] = [];
const pushData = (
start: Date,
end: Date,
dataValues: (number | null)[] | null
dataValues: (number | null)[][]
) => {
if (!dataValues) return;
if (!dataValues.length) return;
if (start > end) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
@ -399,11 +356,10 @@ export class StatisticsChart extends LitElement {
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data.push({ x: prevEndTime.getTime(), y: prevValues[i]! });
// @ts-expect-error
d.data.push({ x: prevEndTime.getTime(), y: null });
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data.push({ x: start.getTime(), y: dataValues[i]! });
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
@ -438,23 +394,25 @@ export class StatisticsChart extends LitElement {
})
: this.statTypes;
let displayed_legend = false;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === "min" || type === "max");
if (!this.hideLegend) {
const show_legend = hasMean
const showLegend = hasMean
? type === "mean"
: displayed_legend === false;
statDatasetExtras.push({
legend_label: name,
show_legend,
});
displayed_legend = displayed_legend || show_legend;
: displayedLegend === false;
if (showLegend) {
statLegendData.push(name);
}
displayedLegend = displayedLegend || showLegend;
}
statTypes.push(type);
statDataSets.push({
label: name
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
data: [],
name: name
? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})
@ -462,25 +420,26 @@ export class StatisticsChart extends LitElement {
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
fill: drawBands
? type === "min" && hasMean
? "+1"
: type === "max"
? "-1"
: false
: false,
borderColor:
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color,
backgroundColor: band ? color + "3F" : color + "7F",
pointRadius: 0,
hidden: !this.hideLegend
? this._hiddenStats.has(statistic_id)
: false,
data: [],
// @ts-ignore
unit: meta?.unit_of_measurement,
band,
});
symbol: "circle",
symbolSize: 0,
lineStyle: {
width: 1.5,
},
color: band && hasMean ? color + "3F" : color,
};
if (band) {
series.stack = "band";
(series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
@ -494,37 +453,39 @@ export class StatisticsChart extends LitElement {
return;
}
prevDate = startDate;
const dataValues: (number | null)[] = [];
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
let val: number | null | undefined;
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val = 0;
val.push(0);
firstSum = stat.sum;
} else {
val = (stat.sum || 0) - firstSum;
val.push((stat.sum || 0) - firstSum);
}
} else if (type === "max") {
const max = stat.max || 0;
val.push(max - (stat.min || 0));
val.push(max);
} else {
val = stat[type];
val.push(stat[type] ?? null);
}
dataValues.push(val ?? null);
dataValues.push(val);
});
pushData(startDate, new Date(stat.end), dataValues);
});
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras);
Array.prototype.push.apply(legendData, statLegendData);
});
if (unit) {
this._createOptions(unit);
this.unit = unit;
}
this._chartData = {
datasets: totalDataSets,
};
this._chartDatasetExtra = totalDatasetExtras;
this._chartData = totalDataSets;
this._legendData = legendData;
this._statisticIds = statisticIds;
}

View File

@ -1,22 +0,0 @@
import type {
BarControllerChartOptions,
BarControllerDatasetOptions,
} from "chart.js";
export interface TimeLineData {
start: Date;
end: Date;
label?: string | null;
color?: string;
}
declare module "chart.js" {
interface ChartTypeRegistry {
timeline: {
chartOptions: BarControllerChartOptions;
datasetOptions: BarControllerDatasetOptions;
defaultDataPoint: TimeLineData;
parsedDataType: any;
};
}
}

View File

@ -1,63 +0,0 @@
import type { BarOptions, BarProps } from "chart.js";
import { BarElement } from "chart.js";
import { hex2rgb } from "../../../common/color/convert-color";
import { luminosity } from "../../../common/color/rgb";
export interface TextBarProps extends BarProps {
text?: string | null;
options?: Partial<TextBaroptions>;
}
export interface TextBaroptions extends BarOptions {
textPad?: number;
textColor?: string;
backgroundColor: string;
}
export class TextBarElement extends BarElement {
static id = "textbar";
draw(ctx: CanvasRenderingContext2D) {
super.draw(ctx);
const options = this.options as TextBaroptions;
const { x, y, base, width, text } = (
this as BarElement<TextBarProps, TextBaroptions>
).getProps(["x", "y", "base", "width", "text"]);
if (!text) {
return;
}
ctx.beginPath();
const textRect = ctx.measureText(text);
if (
textRect.width === 0 ||
textRect.width + (options.textPad || 4) + 2 > width
) {
return;
}
const textColor =
options.textColor ||
(options?.backgroundColor === "transparent"
? "transparent"
: luminosity(hex2rgb(options.backgroundColor)) > 0.5
? "#000"
: "#fff");
// ctx.font = "12px arial";
ctx.fillStyle = textColor;
ctx.lineWidth = 0;
ctx.strokeStyle = textColor;
ctx.textBaseline = "middle";
ctx.fillText(
text,
x - width / 2 + (options.textPad || 4),
y + (base - y) / 2
);
}
tooltipPosition(useFinalPosition: boolean) {
const { x, y, base } = this.getProps(["x", "y", "base"], useFinalPosition);
return { x, y: y + (base - y) / 2 };
}
}

View File

@ -1,255 +0,0 @@
import type { BarElement } from "chart.js";
import { BarController } from "chart.js";
import type { TimeLineData } from "./const";
import type { TextBarProps } from "./textbar-element";
function borderProps(properties) {
let reverse;
let start;
let end;
let top;
let bottom;
if (properties.horizontal) {
reverse = properties.base > properties.x;
start = "left";
end = "right";
} else {
reverse = properties.base < properties.y;
start = "bottom";
end = "top";
}
if (reverse) {
top = "end";
bottom = "start";
} else {
top = "start";
bottom = "end";
}
return { start, end, reverse, top, bottom };
}
function setBorderSkipped(properties, options, stack, index) {
let edge = options.borderSkipped;
const res = {};
if (!edge) {
properties.borderSkipped = res;
return;
}
if (edge === true) {
properties.borderSkipped = {
top: true,
right: true,
bottom: true,
left: true,
};
return;
}
const { start, end, reverse, top, bottom } = borderProps(properties);
if (edge === "middle" && stack) {
properties.enableBorderRadius = true;
if ((stack._top || 0) === index) {
edge = top;
} else if ((stack._bottom || 0) === index) {
edge = bottom;
} else {
res[parseEdge(bottom, start, end, reverse)] = true;
edge = top;
}
}
res[parseEdge(edge, start, end, reverse)] = true;
properties.borderSkipped = res;
}
function parseEdge(edge, a, b, reverse) {
if (reverse) {
edge = swap(edge, a, b);
edge = startEnd(edge, b, a);
} else {
edge = startEnd(edge, a, b);
}
return edge;
}
function swap(orig, v1, v2) {
return orig === v1 ? v2 : orig === v2 ? v1 : orig;
}
function startEnd(v, start, end) {
return v === "start" ? start : v === "end" ? end : v;
}
function setInflateAmount(
properties,
{ inflateAmount }: { inflateAmount?: string | number },
ratio
) {
properties.inflateAmount =
inflateAmount === "auto" ? (ratio === 1 ? 0.33 : 0) : inflateAmount;
}
function parseValue(entry, item, vScale, i) {
const startValue = vScale.parse(entry.start, i);
const endValue = vScale.parse(entry.end, i);
const min = Math.min(startValue, endValue);
const max = Math.max(startValue, endValue);
let barStart = min;
let barEnd = max;
if (Math.abs(min) > Math.abs(max)) {
barStart = max;
barEnd = min;
}
// Store `barEnd` (furthest away from origin) as parsed value,
// to make stacking straight forward
item[vScale.axis] = barEnd;
item._custom = {
barStart,
barEnd,
start: startValue,
end: endValue,
min,
max,
};
return item;
}
export class TimelineController extends BarController {
static id = "timeline";
static defaults = {
dataElementType: "textbar",
dataElementOptions: ["text", "textColor", "textPadding"],
elements: {
showText: true,
textPadding: 4,
minBarWidth: 1,
},
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
};
static overrides = {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
};
parseObjectData(meta, data, start, count) {
const iScale = meta.iScale;
const vScale = meta.vScale;
const labels = iScale.getLabels();
const singleScale = iScale === vScale;
const parsed: any[] = [];
let i;
let ilen;
let item;
let entry;
for (i = start, ilen = start + count; i < ilen; ++i) {
entry = data[i];
item = {};
item[iScale.axis] = singleScale || iScale.parse(labels[i], i);
parsed.push(parseValue(entry, item, vScale, i));
}
return parsed;
}
getLabelAndValue(index) {
const meta = this._cachedMeta;
const { vScale } = meta;
const data = this.getDataset().data[index] as TimeLineData;
return {
label: vScale!.getLabelForValue(this.index) || "",
value: data.label || "",
};
}
updateElements(
bars: BarElement[],
start: number,
count: number,
mode: "reset" | "resize" | "none" | "hide" | "show" | "default" | "active"
) {
const vScale = this._cachedMeta.vScale!;
const iScale = this._cachedMeta.iScale!;
const dataset = this.getDataset();
const firstOpts = this.resolveDataElementOptions(start, mode);
const sharedOptions = this.getSharedOptions(firstOpts);
const includeOptions = this.includeOptions(mode, sharedOptions!);
const horizontal = vScale.isHorizontal();
this.updateSharedOptions(sharedOptions!, mode, firstOpts);
for (let index = start; index < start + count; index++) {
const data = dataset.data[index] as TimeLineData;
const y = vScale.getPixelForValue(this.index);
const xStart = iScale.getPixelForValue(
Math.max(iScale.min, data.start.getTime())
);
const xEnd = iScale.getPixelForValue(data.end.getTime());
const width = xEnd - xStart;
const parsed = this.getParsed(index);
const stack = (parsed._stacks || {})[vScale.axis];
const height = 10;
const properties: TextBarProps = {
horizontal,
x: xStart + width / 2, // Center of the bar
y: y - height, // Top of bar
width,
height: 0,
base: y + height, // Bottom of bar,
// Text
text: data.label,
};
if (includeOptions) {
properties.options =
sharedOptions || this.resolveDataElementOptions(index, mode);
properties.options = {
...properties.options,
backgroundColor: data.color,
};
}
const options = properties.options || bars[index].options;
setBorderSkipped(properties, options, stack, index);
setInflateAmount(properties, options, 1);
this.updateElement(bars[index], index, properties as any, mode);
}
}
removeHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', false);
}
setHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', true);
}
}

View File

@ -1,11 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getGraphColorByIndex } from "../../../common/color/colors";
import { hex2rgb, lab2hex, rgb2lab } from "../../../common/color/convert-color";
import { labBrighten } from "../../../common/color/lab";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorProperties } from "../../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
import { computeCssValue } from "../../../resources/css-variables";
import { getGraphColorByIndex } from "../../common/color/colors";
import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color";
import { labBrighten } from "../../common/color/lab";
import { computeDomain } from "../../common/entity/compute_domain";
import { stateColorProperties } from "../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { computeCssValue } from "../../resources/css-variables";
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
media_player: {

View File

@ -1,13 +1,13 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiPower } from "@mdi/js";
import type { ChartOptions } from "chart.js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { SeriesOption } from "echarts/types/dist/shared";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { numberFormatToLocale } from "../../../common/number/format_number";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/buttons/ha-progress-button";
@ -38,16 +38,22 @@ import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60;
const DATA_SET_CONFIG = {
fill: "origin",
borderColor: DEFAULT_PRIMARY_COLOR,
backgroundColor: DEFAULT_PRIMARY_COLOR + "2B",
pointRadius: 0,
lineTension: 0.2,
borderWidth: 1,
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DEFAULT_PRIMARY_COLOR,
areaStyle: {
color: DEFAULT_PRIMARY_COLOR + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
};
@customElement("ha-config-hardware")
@ -62,15 +68,15 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ChartOptions;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: { x: number; y: number | null }[] = [];
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: { x: number; y: number | null }[] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
@ -121,14 +127,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.memory_used_percent,
});
this._cpuEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.cpu_percent,
});
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
@ -143,51 +149,44 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
}
protected willUpdate(): void {
if (!this.hasUpdated) {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
responsive: true,
scales: {
y: {
gridLines: {
drawTicks: false,
},
ticks: {
maxTicksLimit: 7,
fontSize: 10,
max: 100,
min: 0,
stepSize: 1,
callback: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
},
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
gridLines: {
display: true,
drawTicks: false,
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
fontSize: 10,
autoSkip: true,
maxTicksLimit: 5,
},
axisLine: {
show: false,
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
yAxis: {
type: "value",
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
@ -201,8 +200,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push({ x: t.getTime(), y: null });
this._cpuEntries.push({ x: t.getTime(), y: null });
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
@ -387,14 +386,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._cpuEntries,
},
],
}}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@ -419,14 +411,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._memoryEntries,
},
],
}}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@ -482,6 +467,20 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`

View File

@ -196,6 +196,7 @@ class HaPanelHistory extends LitElement {
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
>
</state-history-charts>
`}

View File

@ -12,7 +12,7 @@ import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { computeTimelineColor } from "../../components/chart/timeline-chart/timeline-color";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import "../../components/entity/state-badge";
import "../../components/ha-circular-progress";
import "../../components/ha-icon-next";

View File

@ -1,18 +1,16 @@
import type { ChartOptions } from "chart.js";
import type { HassConfig } from "home-assistant-js-websocket";
import {
addHours,
subHours,
differenceInDays,
differenceInHours,
} from "date-fns";
import { addHours, subHours, differenceInDays } from "date-fns";
import type {
BarSeriesOption,
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../../../../data/translation";
import {
formatNumber,
numberFormatToLocale,
} from "../../../../../common/number/format_number";
import { formatNumber } from "../../../../../common/number/format_number";
import { formatDateVeryShort } from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label";
export function getSuggestedMax(dayDifference: number, end: Date): number {
let suggestedMax = new Date(end);
@ -46,127 +44,216 @@ export function getCommonOptions(
config: HassConfig,
unit?: string,
compareStart?: Date,
compareEnd?: Date
): ChartOptions {
compareEnd?: Date,
formatTotal?: (total: number) => string
): ECOption {
const dayDifference = differenceInDays(end, start);
const compare = compareStart !== undefined && compareEnd !== undefined;
if (compare && dayDifference <= 35) {
const difference = differenceInHours(end, start);
const differenceCompare = differenceInHours(compareEnd!, compareStart!);
// If the compare period doesn't match the main period, adjust them to match
if (differenceCompare > difference) {
end = addHours(end, differenceCompare - difference);
} else if (difference > differenceCompare) {
compareEnd = addHours(compareEnd!, difference - differenceCompare);
}
}
const options: ChartOptions = {
parsing: false,
interaction: {
mode: "x",
},
scales: {
x: {
type: "time",
suggestedMin: start.getTime(),
max: getSuggestedMax(dayDifference, end),
adapters: {
date: {
locale,
config,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat:
dayDifference > 35
? "monthyear"
: dayDifference > 7
? "date"
: dayDifference > 2
? "weekday"
: dayDifference > 0
? "datetime"
: "hour",
minUnit: getSuggestedPeriod(dayDifference),
},
const options: ECOption = {
xAxis: {
id: "xAxisMain",
type: "time",
min: start.getTime(),
max: getSuggestedMax(dayDifference, end),
axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference),
axisLine: {
show: false,
},
y: {
stacked: true,
type: "linear",
title: {
display: true,
text: unit,
},
ticks: {
beginAtZero: true,
callback: (value) => formatNumber(Math.abs(value), locale),
},
splitLine: {
show: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "value",
name: unit,
nameGap: 5,
axisLabel: {
formatter: (value: number) => formatNumber(Math.abs(value), locale),
},
splitLine: {
show: true,
},
},
plugins: {
tooltip: {
position: "nearest",
filter: (val) => val.formattedValue !== "0",
itemSort: function (a, b) {
return b.datasetIndex - a.datasetIndex;
},
callbacks: {
title: (datasets) => {
if (dayDifference > 0) {
return datasets[0].label;
grid: {
top: 35,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
formatter: (params: TopLevelFormatterParams): string => {
// trigger: "axis" gives an array of params, but "item" gives a single param
if (Array.isArray(params)) {
const mainItems: CallbackDataParams[] = [];
const compareItems: CallbackDataParams[] = [];
params.forEach((param: CallbackDataParams) => {
if (param.seriesId?.startsWith("compare-")) {
compareItems.push(param);
} else {
mainItems.push(param);
}
const date = new Date(datasets[0].parsed.x);
return `${
compare ? `${formatDateVeryShort(date, locale, config)}: ` : ""
}${formatTime(date, locale, config)} ${formatTime(
addHours(date, 1),
locale,
config
)}`;
},
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
locale
)} ${unit}`,
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
},
});
return [mainItems, compareItems]
.filter((items) => items.length > 0)
.map((items) =>
formatTooltip(
items,
locale,
config,
dayDifference,
compare,
unit,
formatTotal
)
)
.join("<br><br>");
}
return formatTooltip(
[params],
locale,
config,
dayDifference,
compare,
unit,
formatTotal
);
},
},
elements: {
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 50,
},
},
// @ts-expect-error
locale: numberFormatToLocale(locale),
};
if (compare) {
options.scales!.xAxisCompare = {
...(options.scales!.x as Record<string, any>),
suggestedMin: compareStart!.getTime(),
max: getSuggestedMax(dayDifference, compareEnd!),
display: false,
};
}
return options;
}
function formatTooltip(
params: CallbackDataParams[],
locale: FrontendLocaleData,
config: HassConfig,
dayDifference: number,
compare: boolean | null,
unit?: string,
formatTotal?: (total: number) => string
) {
if (!params[0].value) {
return "";
}
// when comparing the first value is offset to match the main period
// and the real date is in the third value
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
let period: string;
if (dayDifference > 0) {
period = `${formatDateVeryShort(date, locale, config)}`;
} else {
period = `${
compare ? `${formatDateVeryShort(date, locale, config)}: ` : ""
}${formatTime(date, locale, config)} ${formatTime(
addHours(date, 1),
locale,
config
)}`;
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const values = params
.map((param) => {
const y = param.value?.[1] as number;
const value = formatNumber(y, locale);
if (value === "0") {
return false;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
}
return `${param.marker} ${param.seriesName}: ${value} ${unit}`;
})
.filter(Boolean);
let footer = "";
if (sumPositive !== 0 && countPositive > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumPositive)}</b>`;
}
if (sumNegative !== 0 && countNegative > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumNegative)}</b>`;
}
return values.length > 0 ? `${title}${values.join("<br>")}${footer}` : "";
}
export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) => dataset.data!.map((datapoint) => datapoint![0]))
.flat()
)
).sort((a, b) => a - b);
// make sure all datasets have the same buckets
// otherwise the chart will render incorrectly in some cases
buckets.forEach((bucket, index) => {
const capRounded = {};
const capRoundedNegative = {};
for (let i = datasets.length - 1; i >= 0; i--) {
const dataPoint = datasets[i].data![index];
const item: any =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: { value: dataPoint };
const x = item.value?.[0];
const stack = datasets[i].stack ?? "";
if (x === undefined) {
continue;
}
if (x !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
borderWidth: 0,
},
});
} else if (item.value?.[1] === 0) {
// remove the border for zero values or it will be rendered
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderWidth: 0,
},
};
} else if (!capRounded[stack] && item.value?.[1] > 0) {
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderRadius: [4, 4, 0, 0],
},
};
capRounded[stack] = true;
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderRadius: [0, 0, 4, 4],
},
};
capRoundedNegative[stack] = true;
}
}
});
}

View File

@ -1,9 +1,3 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { endOfToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@ -11,9 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEnergyColor } from "./common/color";
import type { ChartDatasetExtra } from "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import "../../../../components/chart/ha-chart-base";
import type {
@ -29,7 +23,6 @@ import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@ -37,10 +30,13 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesDetailGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions } from "./common/energy-chart-options";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import { clickIsTouch } from "../../../../components/chart/click_is_touch";
import type { ECOption } from "../../../../resources/echarts";
import { formatNumber } from "../../../../common/number/format_number";
const UNIT = "kWh";
@ -53,9 +49,7 @@ export class HuiEnergyDevicesDetailGraphCard
@state() private _config?: EnergyDevicesDetailGraphCardConfig;
@state() private _chartData: ChartData = { datasets: [] };
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
@state() private _chartData: BarSeriesOption[] = [];
@state() private _data?: EnergyData;
@ -74,8 +68,6 @@ export class HuiEnergyDevicesDetailGraphCard
})
private _hiddenStats: string[] = [];
private _untrackedIndex?: number;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@ -133,7 +125,6 @@ export class HuiEnergyDevicesDetailGraphCard
external-hidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
.options=${this._createOptions(
this._start,
this._end,
@ -143,7 +134,6 @@ export class HuiEnergyDevicesDetailGraphCard
this._compareStart,
this._compareEnd
)}
chart-type="bar"
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
@ -152,23 +142,19 @@ export class HuiEnergyDevicesDetailGraphCard
`;
}
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale), unit: UNIT }
);
private _datasetHidden(ev) {
const hiddenEntity =
ev.detail.index === this._untrackedIndex
? "untracked"
: this._data!.prefs.device_consumption[ev.detail.index]
.stat_consumption;
this._hiddenStats = [...this._hiddenStats, hiddenEntity];
this._hiddenStats = [...this._hiddenStats, ev.detail.name];
}
private _datasetUnhidden(ev) {
const hiddenEntity =
ev.detail.index === this._untrackedIndex
? "untracked"
: this._data!.prefs.device_consumption[ev.detail.index]
.stat_consumption;
this._hiddenStats = this._hiddenStats.filter(
(stat) => stat !== hiddenEntity
(stat) => stat !== ev.detail.name
);
}
@ -181,7 +167,7 @@ export class HuiEnergyDevicesDetailGraphCard
unit?: string,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => {
): ECOption => {
const commonOptions = getCommonOptions(
start,
end,
@ -189,44 +175,41 @@ export class HuiEnergyDevicesDetailGraphCard
config,
unit,
compareStart,
compareEnd
compareEnd,
this._formatTotal
);
const options: ChartOptions = {
return {
...commonOptions,
interaction: {
mode: "nearest",
legend: {
show: true,
type: "scroll",
animationDurationUpdate: 400,
selected: this._hiddenStats.reduce((acc, stat) => {
acc[stat] = false;
return acc;
}, {}),
icon: "circle",
},
plugins: {
...commonOptions.plugins!,
legend: {
display: true,
labels: {
usePointStyle: true,
},
},
},
onClick: (event, elements, chart) => {
if (clickIsTouch(event)) return;
const index = elements[0]?.datasetIndex ?? -1;
if (index < 0) return;
const statisticId =
this._data?.prefs.device_consumption[index]?.stat_consumption;
if (!statisticId || isExternalStatistic(statisticId)) return;
fireEvent(this, "hass-more-info", { entityId: statisticId });
chart?.canvas?.dispatchEvent(new Event("mouseout")); // to hide tooltip
grid: {
bottom: 0,
left: 5,
right: 5,
containLabel: true,
},
};
return options;
}
);
private _processStatistics() {
const energyData = this._data!;
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const data = energyData.stats;
const compareData = energyData.statsCompare;
@ -247,21 +230,7 @@ export class HuiEnergyDevicesDetailGraphCard
);
sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]);
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const datasetExtras: ChartDatasetExtra[] = [];
const { data: processedData, dataExtras: processedDataExtras } =
this._processDataSet(
computedStyle,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices
);
datasets.push(...processedData);
datasetExtras.push(...processedDataExtras);
const datasets: BarSeriesOption[] = [];
const { summedData, compareSummedData } = getSummedData(energyData);
@ -277,41 +246,8 @@ export class HuiEnergyDevicesDetailGraphCard
? computeConsumptionData(summedData, compareSummedData)
: { consumption: undefined, compareConsumption: undefined };
if (showUntracked) {
this._untrackedIndex = datasets.length;
const { dataset: untrackedData, datasetExtra: untrackedDataExtra } =
this._processUntracked(
computedStyle,
processedData,
consumptionData,
false
);
datasets.push(untrackedData);
datasetExtras.push(untrackedDataExtra);
}
if (compareData) {
// Add empty dataset to align the bars
datasets.push({
order: 0,
data: [],
});
datasetExtras.push({
show_legend: false,
});
datasets.push({
order: 999,
data: [],
xAxisID: "xAxisCompare",
});
datasetExtras.push({
show_legend: false,
});
const {
data: processedCompareData,
dataExtras: processedCompareDataExtras,
} = this._processDataSet(
const processedCompareData = this._processDataSet(
computedStyle,
compareData,
energyData.statsMetadata,
@ -321,33 +257,51 @@ export class HuiEnergyDevicesDetailGraphCard
);
datasets.push(...processedCompareData);
datasetExtras.push(...processedCompareDataExtras);
if (showUntracked) {
const {
dataset: untrackedCompareData,
datasetExtra: untrackedCompareDataExtra,
} = this._processUntracked(
const untrackedCompareData = this._processUntracked(
computedStyle,
processedCompareData,
consumptionCompareData,
true
);
datasets.push(untrackedCompareData);
datasetExtras.push(untrackedCompareDataExtra);
}
} else {
// add empty dataset so compare bars are first
// `stack: devices` so it doesn't take up space yet
const firstId =
energyData.prefs.device_consumption[0]?.stat_consumption ?? "untracked";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "devices",
data: [],
});
}
this._start = energyData.start;
this._end = energyData.end || endOfToday();
const processedData = this._processDataSet(
computedStyle,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices
);
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
datasets.push(...processedData);
this._chartData = {
datasets,
};
this._chartDatasetExtra = datasetExtras;
if (showUntracked) {
const untrackedData = this._processUntracked(
computedStyle,
processedData,
consumptionData,
false
);
datasets.push(untrackedData);
}
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
private _processUntracked(
@ -355,36 +309,49 @@ export class HuiEnergyDevicesDetailGraphCard
processedData,
consumptionData,
compare: boolean
): { dataset; datasetExtra } {
): BarSeriesOption {
const totalDeviceConsumption: Record<number, number> = {};
processedData.forEach((device) => {
device.data.forEach((datapoint) => {
totalDeviceConsumption[datapoint.x] =
(totalDeviceConsumption[datapoint.x] || 0) + datapoint.y;
totalDeviceConsumption[datapoint[0]] =
(totalDeviceConsumption[datapoint[0]] || 0) + datapoint[1];
});
});
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
const untrackedConsumption: { x: number; y: number }[] = [];
const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => {
untrackedConsumption.push({
x: Number(time),
y: consumptionData.total[time] - (totalDeviceConsumption[time] || 0),
});
const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
if (value > 0) {
const dataPoint = [Number(time), value];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
untrackedConsumption.push(dataPoint);
}
});
const dataset = {
label: this.hass.localize(
const dataset: BarSeriesOption = {
type: "bar",
id: compare ? "compare-untracked" : "untracked",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
hidden: this._hiddenStats.includes("untracked"),
borderColor: getEnergyColor(
computedStyle,
this.hass.themes.darkMode,
false,
compare,
"--state-unavailable-color"
),
backgroundColor: getEnergyColor(
itemStyle: {
borderColor: getEnergyColor(
computedStyle,
this.hass.themes.darkMode,
false,
compare,
"--state-unavailable-color"
),
},
barMaxWidth: 50,
color: getEnergyColor(
computedStyle,
this.hass.themes.darkMode,
true,
@ -392,15 +359,9 @@ export class HuiEnergyDevicesDetailGraphCard
"--state-unavailable-color"
),
data: untrackedConsumption,
order: 1 + this._untrackedIndex!,
stack: "devices",
pointStyle: compare ? false : "circle",
xAxisID: compare ? "xAxisCompare" : undefined,
stack: compare ? "devicesCompare" : "devices",
};
const datasetExtra = {
show_legend: !compare,
};
return { dataset, datasetExtra };
return dataset;
}
private _processDataSet(
@ -411,70 +372,73 @@ export class HuiEnergyDevicesDetailGraphCard
sorted_devices: string[],
compare = false
) {
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const dataExtras: ChartDatasetExtra[] = [];
const data: BarSeriesOption[] = [];
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
devices.forEach((source, idx) => {
const order = sorted_devices.indexOf(source.stat_consumption);
if (this._config?.max_devices && order >= this._config.max_devices) {
// eslint-disable-next-line no-console
console.warn(
`Max devices exceeded for ${source.name} (${order} >= ${this._config.max_devices})`
);
return;
}
const color = getGraphColorByIndex(idx, computedStyle);
let prevStart: number | null = null;
const consumptionData: ScatterDataPoint[] = [];
const consumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (source.stat_consumption in statistics) {
const stats = statistics[source.stat_consumption];
let end;
for (const point of stats) {
if (point.change === null || point.change === undefined) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const date = new Date(point.start);
consumptionData.push({
x: date.getTime(),
y: point.change,
});
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
consumptionData.push(dataPoint);
prevStart = point.start;
end = point.end;
}
if (consumptionData.length === 1) {
consumptionData.push({
x: end,
y: 0,
});
}
}
const order = sorted_devices.indexOf(source.stat_consumption);
const itemExceedsMax = !!(
this._config?.max_devices && order >= this._config.max_devices
);
data.push({
label:
type: "bar",
id: compare
? `compare-${source.stat_consumption}`
: source.stat_consumption,
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
),
hidden:
this._hiddenStats.includes(source.stat_consumption) || itemExceedsMax,
borderColor: compare ? color + "7F" : color,
backgroundColor: compare ? color + "32" : color + "7F",
itemStyle: {
borderColor: compare ? color + "7F" : color,
},
barMaxWidth: 50,
color: compare ? color + "32" : color + "7F",
data: consumptionData,
order: 1 + order,
stack: "devices",
pointStyle: compare ? false : "circle",
xAxisID: compare ? "xAxisCompare" : undefined,
stack: compare ? "devicesCompare" : "devices",
});
dataExtras.push({ show_legend: !compare && !itemExceedsMax });
});
return { data, dataExtras };
return data;
}
static styles = css`

View File

@ -1,40 +1,29 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ParsedDataType,
ScatterDataPoint,
} from "chart.js";
import { getRelativePosition } from "chart.js/helpers";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import type { HaChartBase } from "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { clickIsTouch } from "../../../../components/chart/click_is_touch";
import type { ECOption } from "../../../../resources/echarts";
import "../../../../components/ha-card";
@customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard
@ -45,12 +34,10 @@ export class HuiEnergyDevicesGraphCard
@state() private _config?: EnergyDevicesGraphCardConfig;
@state() private _chartData: ChartData = { datasets: [] };
@state() private _chartData: BarSeriesOption[] = [];
@state() private _data?: EnergyData;
@query("ha-chart-base") private _chart?: HaChartBase;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@ -98,76 +85,53 @@ export class HuiEnergyDevicesGraphCard
<ha-chart-base
.hass=${this.hass}
.data=${this._chartData}
.options=${this._createOptions(this.hass.locale)}
.height=${(this._chartData?.datasets[0]?.data.length || 0) * 28 +
50}
chart-type="bar"
.options=${this._createOptions(this.hass.themes?.darkMode)}
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
></ha-chart-base>
</div>
</ha-card>
`;
}
private _createOptions = memoizeOne(
(locale: FrontendLocaleData): ChartOptions => ({
parsing: false,
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
scales: {
y: {
type: "category",
ticks: {
autoSkip: false,
callback: (index) => {
const statisticId = (
this._chartData.datasets[0].data[index] as ScatterDataPoint
).y;
return this._getDeviceName(statisticId as any as string);
},
},
},
x: {
title: {
display: true,
text: "kWh",
},
},
},
elements: { bar: { borderWidth: 1, borderRadius: 4 } },
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
title: (item) => {
const statisticId = item[0].label;
return this._getDeviceName(statisticId);
},
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.x,
locale
)} kWh`,
},
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (clickIsTouch(e)) return;
const chart = e.chart;
const canvasPosition = getRelativePosition(e, chart);
private _renderTooltip(params: any) {
const title = `<h4 style="text-align: center; margin: 0;">${this._getDeviceName(
params.value[1]
)}</h4>`;
const value = `${formatNumber(
params.value[0] as number,
this.hass.locale,
getNumberFormatOptions(undefined, this.hass.entities[params.value[1]])
)} kWh`;
return `${title}${params.marker} ${params.seriesName}: ${value}`;
}
const index = Math.abs(
chart.scales.y.getValueForPixel(canvasPosition.y)
);
// @ts-ignore
const statisticId = this._chartData?.datasets[0]?.data[index]?.y;
if (!statisticId || isExternalStatistic(statisticId)) return;
fireEvent(this, "hass-more-info", {
entityId: statisticId,
});
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
private _createOptions = memoizeOne(
(darkMode: boolean): ECOption => ({
xAxis: {
type: "value",
name: "kWh",
splitLine: {
lineStyle: darkMode ? { opacity: 0.15 } : {},
},
},
yAxis: {
type: "category",
inverse: true,
axisLabel: {
formatter: this._getDeviceName.bind(this),
overflow: "truncate",
},
},
grid: {
top: 5,
left: 5,
right: 40,
bottom: 0,
containLabel: true,
},
tooltip: {
show: true,
formatter: this._renderTooltip.bind(this),
},
})
);
@ -189,51 +153,53 @@ export class HuiEnergyDevicesGraphCard
const data = energyData.stats;
const compareData = energyData.statsCompare;
const chartData: ChartDataset<"bar", ParsedDataType<"bar">>["data"][] = [];
const chartDataCompare: ChartDataset<
"bar",
ParsedDataType<"bar">
>["data"][] = [];
const borderColor: string[] = [];
const borderColorCompare: string[] = [];
const backgroundColor: string[] = [];
const backgroundColorCompare: string[] = [];
const chartData: NonNullable<BarSeriesOption["data"]> = [];
const chartDataCompare: NonNullable<BarSeriesOption["data"]> = [];
const datasets: ChartDataset<"bar", ParsedDataType<"bar">[]>[] = [
const datasets: BarSeriesOption[] = [
{
label: this.hass.localize(
type: "bar",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_graph.energy_usage"
),
borderColor,
backgroundColor,
itemStyle: {
borderRadius: [0, 4, 4, 0],
},
data: chartData,
barThickness: compareData ? 10 : 20,
barWidth: compareData ? 10 : 20,
},
];
if (compareData) {
datasets.push({
label: this.hass.localize(
type: "bar",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_graph.previous_energy_usage"
),
borderColor: borderColorCompare,
backgroundColor: backgroundColorCompare,
itemStyle: {
borderRadius: [0, 4, 4, 0],
},
data: chartDataCompare,
barThickness: 10,
barWidth: 10,
});
}
energyData.prefs.device_consumption.forEach((device, idx) => {
const computedStyle = getComputedStyle(this);
energyData.prefs.device_consumption.forEach((device, id) => {
const value =
device.stat_consumption in data
? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0
: 0;
const color = getGraphColorByIndex(id, computedStyle);
chartData.push({
// @ts-expect-error
y: device.stat_consumption,
x: value,
idx,
id,
value: [value, device.stat_consumption],
itemStyle: {
color: color + "7F",
borderColor: color,
},
});
if (compareData) {
@ -245,40 +211,22 @@ export class HuiEnergyDevicesGraphCard
: 0;
chartDataCompare.push({
// @ts-expect-error
y: device.stat_consumption,
x: compareValue,
idx,
id,
value: [compareValue, device.stat_consumption],
itemStyle: {
color: color + "32",
borderColor: color + "7F",
},
});
}
});
chartData.sort((a, b) => b.x - a.x);
chartData.sort((a: any, b: any) => b.value[0] - a.value[0]);
chartData.length = this._config?.max_devices || chartData.length;
const computedStyle = getComputedStyle(this);
chartData.forEach((d: any) => {
const color = getGraphColorByIndex(d.idx, computedStyle);
borderColor.push(color);
backgroundColor.push(color + "7F");
});
chartDataCompare.forEach((d: any) => {
const color = getGraphColorByIndex(d.idx, computedStyle);
borderColorCompare.push(color + "7F");
backgroundColorCompare.push(color + "32");
});
this._chartData = {
labels: chartData.map((d) => d.y),
datasets,
};
this._chartData = datasets;
await this.updateComplete;
this._chart?.updateChart("none");
}
static styles = css`

View File

@ -1,9 +1,3 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@ -31,7 +26,11 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyGasGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions } from "./common/energy-chart-options";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@customElement("hui-energy-gas-graph-card")
export class HuiEnergyGasGraphCard
@ -42,9 +41,7 @@ export class HuiEnergyGasGraphCard
@state() private _config?: EnergyGasGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _chartData: BarSeriesOption[] = [];
@state() private _start = startOfToday();
@ -111,7 +108,7 @@ export class HuiEnergyGasGraphCard
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.length
${!this._chartData.length
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
@ -125,6 +122,12 @@ export class HuiEnergyGasGraphCard
`;
}
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
);
private _createOptions = memoizeOne(
(
start: Date,
@ -134,51 +137,26 @@ export class HuiEnergyGasGraphCard
unit?: string,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => {
const commonOptions = getCommonOptions(
): ECOption =>
getCommonOptions(
start,
end,
locale,
config,
unit,
compareStart,
compareEnd
);
const options: ChartOptions = {
...commonOptions,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins!.tooltip,
callbacks: {
...commonOptions.plugins!.tooltip!.callbacks,
footer: (contexts) => {
if (contexts.length < 2) {
return [];
}
let total = 0;
for (const context of contexts) {
total += (context.dataset.data[context.dataIndex] as any).y;
}
if (total === 0) {
return [];
}
return [
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{ num: formatNumber(total, locale), unit }
),
];
},
},
},
},
};
return options;
}
compareEnd,
this._formatTotal
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
@ -188,10 +166,32 @@ export class HuiEnergyGasGraphCard
getEnergyGasUnit(this.hass, energyData.prefs, energyData.statsMetadata) ||
"m³";
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const datasets: BarSeriesOption[] = [];
const computedStyles = getComputedStyle(this);
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: gas` so it doesn't take up space yet
const firstId = gasSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "gas",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
@ -201,38 +201,8 @@ export class HuiEnergyGasGraphCard
)
);
if (energyData.statsCompare) {
// Add empty dataset to align the bars
datasets.push({
order: 0,
data: [],
});
datasets.push({
order: 999,
data: [],
xAxisID: "xAxisCompare",
});
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
true
)
);
}
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
this._chartData = {
datasets,
};
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
private _processDataSet(
@ -242,56 +212,62 @@ export class HuiEnergyGasGraphCard
computedStyles: CSSStyleDeclaration,
compare = false
) {
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const data: BarSeriesOption[] = [];
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
const gasConsumptionData: ScatterDataPoint[] = [];
const gasConsumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
let end;
for (const point of stats) {
if (point.change === null || point.change === undefined) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const date = new Date(point.start);
gasConsumptionData.push({
x: date.getTime(),
y: point.change,
});
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
gasConsumptionData.push(dataPoint);
prevStart = point.start;
end = point.end;
}
if (gasConsumptionData.length === 1) {
gasConsumptionData.push({
x: end,
y: 0,
});
}
}
data.push({
label: getStatisticLabel(
type: "bar",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
backgroundColor: getEnergyColor(
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
@ -300,9 +276,7 @@ export class HuiEnergyGasGraphCard
idx
),
data: gasConsumptionData,
order: 1,
stack: "gas",
xAxisID: compare ? "xAxisCompare" : undefined,
stack: compare ? "compare-gas" : "gas",
});
});
return data;

View File

@ -1,9 +1,3 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@ -32,7 +27,11 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergySolarGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions } from "./common/energy-chart-options";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@customElement("hui-energy-solar-graph-card")
export class HuiEnergySolarGraphCard
@ -43,9 +42,7 @@ export class HuiEnergySolarGraphCard
@state() private _config?: EnergySolarGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _chartData: ECOption["series"][] = [];
@state() private _start = startOfToday();
@ -109,7 +106,7 @@ export class HuiEnergySolarGraphCard
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.length
${!this._chartData.length
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
@ -123,6 +120,12 @@ export class HuiEnergySolarGraphCard
`;
}
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.total_produced",
{ num: formatNumber(total, this.hass.locale) }
);
private _createOptions = memoizeOne(
(
start: Date,
@ -131,64 +134,26 @@ export class HuiEnergySolarGraphCard
config: HassConfig,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => {
const commonOptions = getCommonOptions(
): ECOption =>
getCommonOptions(
start,
end,
locale,
config,
"kWh",
compareStart,
compareEnd
);
const options: ChartOptions = {
...commonOptions,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins!.tooltip,
callbacks: {
...commonOptions.plugins!.tooltip!.callbacks,
footer: (contexts) => {
const production_contexts = contexts.filter(
(c) => c.dataset?.stack === "solar"
);
if (production_contexts.length < 2) {
return [];
}
let total = 0;
for (const context of production_contexts) {
total += (context.dataset.data[context.dataIndex] as any).y;
}
if (total === 0) {
return [];
}
return [
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.total_produced",
{ num: formatNumber(total, locale) }
),
];
},
},
},
},
elements: {
line: {
tension: 0.3,
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
},
},
};
return options;
}
compareEnd,
this._formatTotal
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const solarSources: SolarSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "solar"
@ -205,10 +170,32 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: ChartDataset<"bar" | "line">[] = [];
const datasets: ECOption["series"] = [];
const computedStyles = getComputedStyle(this);
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: solar` so it doesn't take up space yet
const firstId = solarSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "solar",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
@ -218,28 +205,7 @@ export class HuiEnergySolarGraphCard
)
);
if (energyData.statsCompare) {
// Add empty dataset to align the bars
datasets.push({
order: 0,
data: [],
});
datasets.push({
order: 999,
data: [],
xAxisID: "xAxisCompare",
});
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
true
)
);
}
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
if (forecasts) {
datasets.push(
@ -254,15 +220,7 @@ export class HuiEnergySolarGraphCard
);
}
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
this._chartData = {
datasets,
};
this._chartData = datasets;
}
private _processDataSet(
@ -272,43 +230,47 @@ export class HuiEnergySolarGraphCard
computedStyles: CSSStyleDeclaration,
compare = false
) {
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const data: BarSeriesOption[] = [];
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
const solarProductionData: ScatterDataPoint[] = [];
const solarProductionData: BarSeriesOption["data"] = [];
// Process solar production data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
let end;
for (const point of stats) {
if (point.change === null || point.change === undefined) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const date = new Date(point.start);
solarProductionData.push({
x: date.getTime(),
y: point.change,
});
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
solarProductionData.push(dataPoint);
prevStart = point.start;
end = point.end;
}
if (solarProductionData.length === 1) {
solarProductionData.push({
x: end,
y: 0,
});
}
}
data.push({
label: this.hass.localize(
type: "bar",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
{
name: getStatisticLabel(
@ -318,15 +280,18 @@ export class HuiEnergySolarGraphCard
),
}
),
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-solar-color",
idx
),
backgroundColor: getEnergyColor(
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-solar-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
@ -335,9 +300,7 @@ export class HuiEnergySolarGraphCard
idx
),
data: solarProductionData,
order: 1,
stack: "solar",
xAxisID: compare ? "xAxisCompare" : undefined,
stack: compare ? "compare" : "solar",
});
});
@ -352,7 +315,7 @@ export class HuiEnergySolarGraphCard
start: Date,
end?: Date
) {
const data: ChartDataset<"line">[] = [];
const data: LineSeriesOption[] = [];
const dayDifference = differenceInDays(end || new Date(), start);
@ -389,18 +352,16 @@ export class HuiEnergySolarGraphCard
});
if (forecastsData) {
const solarForecastData: ScatterDataPoint[] = [];
const solarForecastData: LineSeriesOption["data"] = [];
for (const [time, value] of Object.entries(forecastsData)) {
solarForecastData.push({
x: Number(time),
y: value / 1000,
});
solarForecastData.push([Number(time), value / 1000]);
}
if (solarForecastData.length) {
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
label: this.hass.localize(
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
name: getStatisticLabel(
@ -410,11 +371,13 @@ export class HuiEnergySolarGraphCard
),
}
),
fill: false,
stepped: false,
borderColor,
borderDash: [7, 5],
pointRadius: 0,
step: false,
color: borderColor,
lineStyle: {
type: [7, 5],
width: 1.5,
},
symbol: "none",
data: solarForecastData,
});
}

View File

@ -1,9 +1,3 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@ -11,6 +5,11 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import type {
TooltipOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@ -25,7 +24,11 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyUsageGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions } from "./common/energy-chart-options";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@ -45,9 +48,7 @@ export class HuiEnergyUsageGraphCard
@state() private _config?: EnergyUsageGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _chartData: BarSeriesOption[] = [];
@state() private _start = startOfToday();
@ -111,7 +112,7 @@ export class HuiEnergyUsageGraphCard
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.some((dataset) => dataset.data.length)
${!this._chartData.some((dataset) => dataset.data!.length)
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
@ -125,6 +126,17 @@ export class HuiEnergyUsageGraphCard
`;
}
private _formatTotal = (total: number) =>
total > 0
? this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale) }
)
: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned",
{ num: formatNumber(-total, this.hass.locale) }
);
private _createOptions = memoizeOne(
(
start: Date,
@ -133,7 +145,7 @@ export class HuiEnergyUsageGraphCard
config: HassConfig,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => {
): ECOption => {
const commonOptions = getCommonOptions(
start,
end,
@ -141,56 +153,34 @@ export class HuiEnergyUsageGraphCard
config,
"kWh",
compareStart,
compareEnd
compareEnd,
this._formatTotal
);
const options: ChartOptions = {
const options: ECOption = {
...commonOptions,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins!.tooltip,
itemSort: function (a: any, b: any) {
if (a.raw?.y > 0 && b.raw?.y < 0) {
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams): string => {
if (!Array.isArray(params)) {
return "";
}
params.sort((a, b) => {
const aValue = (a.value as number[])?.[1];
const bValue = (b.value as number[])?.[1];
if (aValue > 0 && bValue < 0) {
return -1;
}
if (b.raw?.y > 0 && a.raw?.y < 0) {
if (bValue > 0 && aValue < 0) {
return 1;
}
if (a.raw?.y > 0) {
return b.datasetIndex - a.datasetIndex;
if (aValue > 0) {
return b.componentIndex - a.componentIndex;
}
return a.datasetIndex - b.datasetIndex;
},
callbacks: {
...commonOptions.plugins!.tooltip!.callbacks,
footer: (contexts) => {
let totalConsumed = 0;
let totalReturned = 0;
for (const context of contexts) {
const value = (context.dataset.data[context.dataIndex] as any)
.y;
if (value > 0) {
totalConsumed += value;
} else {
totalReturned += Math.abs(value);
}
}
return [
totalConsumed
? this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(totalConsumed, locale) }
)
: "",
totalReturned
? this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned",
{ num: formatNumber(totalReturned, locale) }
)
: "",
].filter(Boolean);
},
},
return a.componentIndex - b.componentIndex;
});
return (
(commonOptions.tooltip as TooltipOption)?.formatter as any
)?.(params);
},
},
};
@ -199,7 +189,7 @@ export class HuiEnergyUsageGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const datasets: BarSeriesOption[] = [];
const statIds: {
to_grid?: string[];
@ -288,6 +278,30 @@ export class HuiEnergyUsageGraphCard
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
statIds,
colorIndices,
computedStyles,
labels,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: usage` so it doesn't take up space yet
const firstId = statIds.from_grid?.[0] ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "usage",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
@ -300,34 +314,8 @@ export class HuiEnergyUsageGraphCard
)
);
if (energyData.statsCompare) {
// Add empty dataset to align the bars
datasets.push({
order: 0,
data: [],
});
datasets.push({
order: 999,
data: [],
xAxisID: "xAxisCompare",
});
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
statIds,
colorIndices,
computedStyles,
labels,
true
)
);
}
this._chartData = {
datasets,
};
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
private _processDataSet(
@ -349,7 +337,7 @@ export class HuiEnergyUsageGraphCard
},
compare = false
) {
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const data: BarSeriesOption[] = [];
const combinedData: {
to_grid?: Record<string, Record<number, number>>;
@ -368,7 +356,6 @@ export class HuiEnergyUsageGraphCard
solar?: Record<number, number>;
} = {};
let pointEndTime;
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
const sum = [
"solar",
@ -396,11 +383,9 @@ export class HuiEnergyUsageGraphCard
if (sum) {
totalStats[stat.start] =
stat.start in totalStats ? totalStats[stat.start] + val : val;
pointEndTime = stat.end;
}
if (add && !(stat.start in set)) {
set[stat.start] = val;
pointEndTime = stat.end;
}
});
sets[id] = set;
@ -491,29 +476,33 @@ export class HuiEnergyUsageGraphCard
(a, b) => Number(a) - Number(b)
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source], idx) => {
const points: ScatterDataPoint[] = [];
Object.entries(sources).forEach(([statId, source]) => {
const points: BarSeriesOption["data"] = [];
// Process chart data.
for (const key of uniqueKeys) {
const value = source[key] || 0;
points.push({
x: Number(key),
y:
value && ["to_grid", "to_battery"].includes(type)
? -1 * value
: value,
});
}
if (points.length === 1) {
points.push({
x: pointEndTime,
y: 0,
});
const dataPoint = [
Number(key),
value && ["to_grid", "to_battery"].includes(type)
? -1 * value
: value,
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
points.push(dataPoint);
}
data.push({
label:
id: compare ? "compare-" + statId : statId,
type: "bar",
name:
type in labels
? labels[type]
: getStatisticLabel(
@ -521,21 +510,18 @@ export class HuiEnergyUsageGraphCard
statId,
statisticsMetaData[statId]
),
order:
type === "used_solar"
? 1
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 2,
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
colorPropertyMap[type],
colorIndices[type]?.[statId]
),
backgroundColor: getEnergyColor(
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
colorPropertyMap[type],
colorIndices[type]?.[statId]
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
@ -543,9 +529,8 @@ export class HuiEnergyUsageGraphCard
colorPropertyMap[type],
colorIndices[type]?.[statId]
),
stack: "stack",
stack: compare ? "compare" : "usage",
data: points,
xAxisID: compare ? "xAxisCompare" : undefined,
});
});
});

View File

@ -1,9 +1,3 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@ -11,8 +5,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type {
@ -31,7 +25,12 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyWaterGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions } from "./common/energy-chart-options";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import { formatNumber } from "../../../../common/number/format_number";
@customElement("hui-energy-water-graph-card")
export class HuiEnergyWaterGraphCard
@ -42,9 +41,7 @@ export class HuiEnergyWaterGraphCard
@state() private _config?: EnergyWaterGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _chartData: BarSeriesOption[] = [];
@state() private _start = startOfToday();
@ -111,7 +108,7 @@ export class HuiEnergyWaterGraphCard
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.length
${!this._chartData.length
? html`<div class="no-data">
${isToday(this._start)
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
@ -125,6 +122,12 @@ export class HuiEnergyWaterGraphCard
`;
}
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_water_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
);
private _createOptions = memoizeOne(
(
start: Date,
@ -134,51 +137,26 @@ export class HuiEnergyWaterGraphCard
unit?: string,
compareStart?: Date,
compareEnd?: Date
): ChartOptions => {
const commonOptions = getCommonOptions(
): ECOption =>
getCommonOptions(
start,
end,
locale,
config,
unit,
compareStart,
compareEnd
);
const options: ChartOptions = {
...commonOptions,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins!.tooltip,
callbacks: {
...commonOptions.plugins!.tooltip!.callbacks,
footer: (contexts) => {
if (contexts.length < 2) {
return [];
}
let total = 0;
for (const context of contexts) {
total += (context.dataset.data[context.dataIndex] as any).y;
}
if (total === 0) {
return [];
}
return [
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_water_graph.total_consumed",
{ num: formatNumber(total, locale), unit }
),
];
},
},
},
},
};
return options;
}
compareEnd,
this._formatTotal
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const waterSources: WaterSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "water"
@ -186,10 +164,32 @@ export class HuiEnergyWaterGraphCard
this._unit = getEnergyWaterUnit(this.hass);
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const datasets: BarSeriesOption[] = [];
const computedStyles = getComputedStyle(this);
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
waterSources,
computedStyles,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: water` so it doesn't take up space yet
const firstId = waterSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "water",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
@ -199,38 +199,8 @@ export class HuiEnergyWaterGraphCard
)
);
if (energyData.statsCompare) {
// Add empty dataset to align the bars
datasets.push({
order: 0,
data: [],
});
datasets.push({
order: 999,
data: [],
xAxisID: "xAxisCompare",
});
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
waterSources,
computedStyles,
true
)
);
}
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
this._chartData = {
datasets,
};
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
private _processDataSet(
@ -240,56 +210,62 @@ export class HuiEnergyWaterGraphCard
computedStyles: CSSStyleDeclaration,
compare = false
) {
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
const data: BarSeriesOption[] = [];
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
waterSources.forEach((source, idx) => {
let prevStart: number | null = null;
const waterConsumptionData: ScatterDataPoint[] = [];
const waterConsumptionData: BarSeriesOption["data"] = [];
// Process water consumption data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
let end;
for (const point of stats) {
if (point.change === null || point.change === undefined) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const date = new Date(point.start);
waterConsumptionData.push({
x: date.getTime(),
y: point.change,
});
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset;
}
waterConsumptionData.push(dataPoint);
prevStart = point.start;
end = point.end;
}
if (waterConsumptionData.length === 1) {
waterConsumptionData.push({
x: end,
y: 0,
});
}
}
data.push({
label: getStatisticLabel(
type: "bar",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-water-color",
idx
),
backgroundColor: getEnergyColor(
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-water-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
@ -298,9 +274,7 @@ export class HuiEnergyWaterGraphCard
idx
),
data: waterConsumptionData,
order: 1,
stack: "water",
xAxisID: compare ? "xAxisCompare" : undefined,
stack: compare ? "compare" : "water",
});
});
return data;

View File

@ -64,6 +64,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: 6,
min_columns: 6,
min_rows: (this._config?.entities?.length || 1) * 2,
};
@ -280,6 +281,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false}
height="100%"
></state-history-charts>
`}
</div>
@ -289,6 +291,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
static styles = css`
ha-card {
display: flex;
flex-direction: column;
height: 100%;
}
.card-header {
@ -302,10 +306,14 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
}
.content {
padding: 16px;
flex: 1;
}
.has-header {
padding-top: 0;
}
state-history-charts {
height: 100%;
}
`;
}

View File

@ -131,6 +131,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: 5,
min_columns: 8,
min_rows: 4,
};
@ -277,6 +278,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false}
.logarithmicScale=${this._config.logarithmic_scale || false}
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
height="100%"
></statistics-chart>
</div>
</ha-card>
@ -352,14 +355,20 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
static styles = css`
ha-card {
display: flex;
flex-direction: column;
height: 100%;
}
.content {
padding: 16px;
flex: 1;
}
.has-header {
padding-top: 0;
}
statistics-chart {
height: 100%;
}
`;
}

View File

@ -1,41 +0,0 @@
import ZoomPlugin from "chartjs-plugin-zoom";
import {
LineController,
TimeScale,
LinearScale,
PointElement,
LineElement,
Filler,
Legend,
Title,
Tooltip,
CategoryScale,
Chart,
BarElement,
BarController,
LogarithmicScale,
} from "chart.js";
import { TextBarElement } from "../components/chart/timeline-chart/textbar-element";
import { TimelineController } from "../components/chart/timeline-chart/timeline-controller";
import "../components/chart/chart-date-adapter";
export { Chart } from "chart.js";
Chart.register(
Tooltip,
Title,
Legend,
Filler,
TimeScale,
LinearScale,
LineController,
BarController,
BarElement,
PointElement,
LineElement,
TextBarElement,
TimelineController,
CategoryScale,
LogarithmicScale,
ZoomPlugin
);

72
src/resources/echarts.ts Normal file
View File

@ -0,0 +1,72 @@
// Import the echarts core module, which provides the necessary interfaces for using echarts.
import * as echarts from "echarts/core";
// Import charts, all suffixed with Chart
import { BarChart, LineChart, CustomChart } from "echarts/charts";
// Import the title, tooltip, rectangular coordinate system, dataset and transform components
import {
TooltipComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
VisualMapComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
import { LabelLayout, UniversalTransition } from "echarts/features";
// Import the Canvas renderer
// Note that including the CanvasRenderer or SVGRenderer is a required step
import { CanvasRenderer } from "echarts/renderers";
import type {
// The series option types are defined with the SeriesOption suffix
BarSeriesOption,
LineSeriesOption,
CustomSeriesOption,
} from "echarts/charts";
import type {
// The component option types are defined with the ComponentOption suffix
TooltipComponentOption,
DatasetComponentOption,
LegendComponentOption,
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
// Create an Option type with only the required components and charts via ComposeOption
export type ECOption = ComposeOption<
| BarSeriesOption
| LineSeriesOption
| CustomSeriesOption
| TooltipComponentOption
| DatasetComponentOption
| LegendComponentOption
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
>;
// Register the required components
echarts.use([
BarChart,
LineChart,
CustomChart,
TooltipComponent,
DatasetComponent,
LegendComponent,
GridComponent,
TransformComponent,
DataZoomComponent,
VisualMapComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
]);
export default echarts;

View File

@ -834,8 +834,6 @@
"duration": "Duration",
"source_history": "Source: History",
"source_stats": "Source: Long term statistics",
"zoom_hint": "Use ctrl + scroll to zoom in/out",
"zoom_hint_mac": "Use ⌘ + scroll to zoom in/out",
"zoom_reset": "Reset zoom"
},
"map": {

25
src/util/text.ts Normal file
View File

@ -0,0 +1,25 @@
let textMeasureCanvas: HTMLCanvasElement | undefined;
/**
* Measures the width of text in pixels using a canvas context
* @param text The text to measure
* @param fontSize The font size in pixels
* @param fontFamily The font family to use (defaults to sans-serif)
* @returns Width of the text in pixels
*/
export function measureTextWidth(
text: string,
fontSize: number,
fontFamily = "sans-serif"
): number {
if (!textMeasureCanvas) {
textMeasureCanvas = document.createElement("canvas");
}
const context = textMeasureCanvas.getContext("2d");
if (!context) {
return 0;
}
context.font = `${fontSize}px ${fontFamily}`;
return context.measureText(text).width;
}

View File

@ -2174,13 +2174,6 @@ __metadata:
languageName: node
linkType: hard
"@kurkle/color@npm:^0.3.0":
version: 0.3.4
resolution: "@kurkle/color@npm:0.3.4"
checksum: 10/a15bfb55eec4ff92ee27cdacff54e85c7497ec1f9e8e0e8eaec3d39320cc90b82eb7f716d92792a8235fad1617003c6e0c0d2cd07f6a79b7b138ca17ec5eb6e7
languageName: node
linkType: hard
"@leichtgewicht/ip-codec@npm:^2.0.1":
version: 2.0.5
resolution: "@leichtgewicht/ip-codec@npm:2.0.5"
@ -4650,7 +4643,7 @@ __metadata:
languageName: node
linkType: hard
"@types/hammerjs@npm:^2.0.36, @types/hammerjs@npm:^2.0.45":
"@types/hammerjs@npm:^2.0.36":
version: 2.0.46
resolution: "@types/hammerjs@npm:2.0.46"
checksum: 10/1b6502d668f45ca49fb488c01f7938d3aa75e989d70c64801c8feded7d659ca1a118f745c1b604d220efe344c93231767d5cc68c05e00e069c14539b6143cfd9
@ -6434,27 +6427,6 @@ __metadata:
languageName: node
linkType: hard
"chart.js@npm:4.4.7":
version: 4.4.7
resolution: "chart.js@npm:4.4.7"
dependencies:
"@kurkle/color": "npm:^0.3.0"
checksum: 10/f80c5e61adf8118d71d189db431f9bf82864e42d19d1c19008afdcf5e8933545f1f8a8662a4e7809f21a518781f12f2242657864c863016303cbf63a2aade72b
languageName: node
linkType: hard
"chartjs-plugin-zoom@npm:2.2.0":
version: 2.2.0
resolution: "chartjs-plugin-zoom@npm:2.2.0"
dependencies:
"@types/hammerjs": "npm:^2.0.45"
hammerjs: "npm:^2.0.8"
peerDependencies:
chart.js: ">=3.2.0"
checksum: 10/4a549b1b21ed5433f9ba67038d6176ed545b2881521e12d6b8024cd2ab08fb008c36fe388ab2ac7ee2ac334bf44a8d785703570388fa0e0b4c22c18602536f9c
languageName: node
linkType: hard
"check-error@npm:^2.1.1":
version: 2.1.1
resolution: "check-error@npm:2.1.1"
@ -7355,6 +7327,16 @@ __metadata:
languageName: node
linkType: hard
"echarts@npm:5.6.0":
version: 5.6.0
resolution: "echarts@npm:5.6.0"
dependencies:
tslib: "npm:2.3.0"
zrender: "npm:5.6.1"
checksum: 10/e73344abb777fd8401c0b89a5d83b65c7a81a11540e2047d51f4aae9419baf4dc2524a5d9561f9ca0fe8d6b432c58b7a1d518f2a4338041046506db8257a1332
languageName: node
linkType: hard
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@ -9038,13 +9020,6 @@ __metadata:
languageName: node
linkType: hard
"hammerjs@npm:^2.0.8":
version: 2.0.8
resolution: "hammerjs@npm:2.0.8"
checksum: 10/9155d056f252ef35e8ca258dbb5ee2c9d8794f6805d083da7d1d9763d185e3e149459ecc2b36ccce584e3cd5f099fd9fa55056e3bcc7724046390f2e5ae25815
languageName: node
linkType: hard
"handle-thing@npm:^2.0.0":
version: 2.0.1
resolution: "handle-thing@npm:2.0.1"
@ -9253,8 +9228,6 @@ __metadata:
babel-plugin-template-html-minifier: "npm:4.1.0"
barcode-detector: "npm:2.3.1"
browserslist-useragent-regexp: "npm:4.1.3"
chart.js: "npm:4.4.7"
chartjs-plugin-zoom: "npm:2.2.0"
color-name: "npm:2.0.0"
comlink: "npm:4.4.2"
core-js: "npm:3.40.0"
@ -9265,6 +9238,7 @@ __metadata:
deep-freeze: "npm:0.0.1"
del: "npm:8.0.0"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:5.6.0"
element-internals-polyfill: "npm:1.3.12"
eslint: "npm:9.18.0"
eslint-config-airbnb-base: "npm:15.0.0"
@ -14179,7 +14153,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:2, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0":
"tslib@npm:^2.3.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7
@ -15922,6 +15896,15 @@ __metadata:
languageName: node
linkType: hard
"zrender@npm:5.6.1":
version: 5.6.1
resolution: "zrender@npm:5.6.1"
dependencies:
tslib: "npm:2.3.0"
checksum: 10/25dfd476be243f051614f131675855d184eb05e8b9a39dc41146a3a553a17aad5ceba77e166d525be9c4adc2bb237c56dfdfb3456667940bbeddb05eef3deac7
languageName: node
linkType: hard
"zxing-wasm@npm:1.3.4":
version: 1.3.4
resolution: "zxing-wasm@npm:1.3.4"