mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
Migrate from chart.js to echarts (#23809)
This commit is contained in:
parent
62a9da2de2
commit
1f8cfdd0de
@ -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"
|
||||
}
|
||||
|
62
src/components/chart/axis-label.ts
Normal file
62
src/components/chart/axis-label.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
@ -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");
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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> ";
|
||||
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;
|
||||
}
|
||||
|
@ -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`
|
||||
|
@ -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> `;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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: {
|
@ -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`
|
||||
|
@ -196,6 +196,7 @@ class HaPanelHistory extends LitElement {
|
||||
.historyData=${this._mungedStateHistory}
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
>
|
||||
</state-history-charts>
|
||||
`}
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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`
|
||||
|
@ -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`
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -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%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -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
72
src/resources/echarts.ts
Normal 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;
|
@ -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
25
src/util/text.ts
Normal 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;
|
||||
}
|
61
yarn.lock
61
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user