mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-25 18:26:35 +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",
|
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||||
"app-datepicker": "5.1.1",
|
"app-datepicker": "5.1.1",
|
||||||
"barcode-detector": "2.3.1",
|
"barcode-detector": "2.3.1",
|
||||||
"chart.js": "4.4.7",
|
|
||||||
"chartjs-plugin-zoom": "2.2.0",
|
|
||||||
"color-name": "2.0.0",
|
"color-name": "2.0.0",
|
||||||
"comlink": "4.4.2",
|
"comlink": "4.4.2",
|
||||||
"core-js": "3.40.0",
|
"core-js": "3.40.0",
|
||||||
@ -110,6 +108,7 @@
|
|||||||
"deep-clone-simple": "1.1.1",
|
"deep-clone-simple": "1.1.1",
|
||||||
"deep-freeze": "0.0.1",
|
"deep-freeze": "0.0.1",
|
||||||
"dialog-polyfill": "0.5.6",
|
"dialog-polyfill": "0.5.6",
|
||||||
|
"echarts": "5.6.0",
|
||||||
"element-internals-polyfill": "1.3.12",
|
"element-internals-polyfill": "1.3.12",
|
||||||
"fuse.js": "7.0.0",
|
"fuse.js": "7.0.0",
|
||||||
"google-timezones-json": "1.2.0",
|
"google-timezones-json": "1.2.0",
|
||||||
@ -239,7 +238,8 @@
|
|||||||
"clean-css": "5.3.3",
|
"clean-css": "5.3.3",
|
||||||
"@lit/reactive-element": "1.6.3",
|
"@lit/reactive-element": "1.6.3",
|
||||||
"@fullcalendar/daygrid": "6.1.15",
|
"@fullcalendar/daygrid": "6.1.15",
|
||||||
"globals": "15.14.0"
|
"globals": "15.14.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.6.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 type { PropertyValues } from "lit";
|
||||||
import { css, html, nothing, LitElement } from "lit";
|
import { css, html, nothing, LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
|
||||||
import { styleMap } from "lit/directives/style-map";
|
import { styleMap } from "lit/directives/style-map";
|
||||||
import { mdiRestart } from "@mdi/js";
|
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 { fireEvent } from "../../common/dom/fire_event";
|
||||||
import { clamp } from "../../common/number/clamp";
|
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import { debounce } from "../../common/util/debounce";
|
|
||||||
import { isMac } from "../../util/is_mac";
|
import { isMac } from "../../util/is_mac";
|
||||||
import "../ha-icon-button";
|
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;
|
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")
|
@customElement("ha-chart-base")
|
||||||
export class HaChartBase extends LitElement {
|
export class HaChartBase extends LitElement {
|
||||||
public chart?: Chart;
|
public chart?: EChartsType;
|
||||||
|
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: "chart-type", reflect: true })
|
@property({ attribute: false }) public data: ECOption["series"] = [];
|
||||||
public chartType: ChartType = "line";
|
|
||||||
|
|
||||||
@property({ attribute: false }) public data: ChartData = { datasets: [] };
|
@property({ attribute: false }) public options?: ECOption;
|
||||||
|
|
||||||
@property({ attribute: false }) public extraData?: ChartDatasetExtra[];
|
@property({ type: String }) public height?: string;
|
||||||
|
|
||||||
@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({ attribute: "external-hidden", type: Boolean })
|
@property({ attribute: "external-hidden", type: Boolean })
|
||||||
public externalHidden = false;
|
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;
|
@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() {
|
public disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
window.removeEventListener("scroll", this._handleScroll, true);
|
while (this._listeners.length) {
|
||||||
this._releaseCanvas();
|
this._listeners.pop()!();
|
||||||
|
}
|
||||||
|
this.chart?.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public connectedCallback() {
|
public connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener("scroll", this._handleScroll, true);
|
|
||||||
if (this.hasUpdated) {
|
if (this.hasUpdated) {
|
||||||
this._releaseCanvas();
|
|
||||||
this._setupChart();
|
this._setupChart();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public updateChart = (mode?: UpdateMode): void => {
|
this._listeners.push(
|
||||||
this.chart?.update(mode);
|
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() {
|
protected firstUpdated() {
|
||||||
this._setupChart();
|
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 {
|
public willUpdate(changedProps: PropertyValues): void {
|
||||||
super.willUpdate(changedProps);
|
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) {
|
if (!this.hasUpdated || !this.chart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (changedProps.has("plugins") || changedProps.has("chartType")) {
|
|
||||||
this._releaseCanvas();
|
|
||||||
this._setupChart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (changedProps.has("data")) {
|
if (changedProps.has("data")) {
|
||||||
if (this._hiddenDatasets.size && !this.externalHidden) {
|
this.chart.setOption(
|
||||||
this.data.datasets.forEach((dataset, index) => {
|
{ series: this.data },
|
||||||
dataset.hidden = this._hiddenDatasets.has(index);
|
{ lazyUpdate: true, replaceMerge: ["series"] }
|
||||||
});
|
);
|
||||||
}
|
|
||||||
this.chart.data = this.data;
|
|
||||||
}
|
}
|
||||||
if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) {
|
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
|
||||||
// this resets the chart zoom because min/max scales changed
|
this.chart.setOption(this._createOptions(), {
|
||||||
// so we only do it if the user is not zooming or panning
|
lazyUpdate: true,
|
||||||
this.chart.options = this._createOptions();
|
// if we replace the whole object, it will reset the dataZoom
|
||||||
}
|
replaceMerge: [
|
||||||
this.chart.update("none");
|
"xAxis",
|
||||||
}
|
"yAxis",
|
||||||
|
"dataZoom",
|
||||||
protected updated(changedProperties: PropertyValues): void {
|
"dataset",
|
||||||
super.updated(changedProperties);
|
"tooltip",
|
||||||
if (changedProperties.has("data") || changedProperties.has("options")) {
|
"legend",
|
||||||
if (this.options?.plugins?.legend?.display) {
|
"grid",
|
||||||
this._legendHeight =
|
"visualMap",
|
||||||
this.renderRoot.querySelector(".chart-legend")?.clientHeight;
|
],
|
||||||
} else {
|
});
|
||||||
this._legendHeight = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
return html`
|
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
|
<div
|
||||||
class="chart-container"
|
class="chart-container"
|
||||||
style=${styleMap({
|
style=${styleMap({
|
||||||
height: `${this.height ?? this._getDefaultHeight()}px`,
|
height: this.height ?? `${this._getDefaultHeight()}px`,
|
||||||
"padding-left": `${this._paddingYAxisInternal}px`,
|
|
||||||
"padding-right": 0,
|
|
||||||
"padding-inline-start": `${this._paddingYAxisInternal}px`,
|
|
||||||
"padding-inline-end": 0,
|
|
||||||
})}
|
})}
|
||||||
@wheel=${this._handleChartScroll}
|
@wheel=${this._handleWheel}
|
||||||
>
|
>
|
||||||
<canvas
|
<div class="chart"></div>
|
||||||
class=${classMap({
|
${this._isZoomed
|
||||||
"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"
|
|
||||||
? html`<ha-icon-button
|
? html`<ha-icon-button
|
||||||
class="zoom-reset"
|
class="zoom-reset"
|
||||||
.path=${mdiRestart}
|
.path=${mdiRestart}
|
||||||
@ -270,261 +154,145 @@ export class HaChartBase extends LitElement {
|
|||||||
)}
|
)}
|
||||||
></ha-icon-button>`
|
></ha-icon-button>`
|
||||||
: nothing}
|
: 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _loading = false;
|
|
||||||
|
|
||||||
private async _setupChart() {
|
private async _setupChart() {
|
||||||
if (this._loading) return;
|
if (this._loading) return;
|
||||||
const ctx: CanvasRenderingContext2D = this.renderRoot
|
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
|
||||||
.querySelector("canvas")!
|
|
||||||
.getContext("2d")!;
|
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
const echarts = (await import("../../resources/echarts")).default;
|
||||||
const ChartConstructor = (await import("../../resources/chartjs")).Chart;
|
|
||||||
|
|
||||||
const computedStyles = getComputedStyle(this);
|
this.chart = echarts.init(container);
|
||||||
|
this.chart.on("legendselectchanged", (params: any) => {
|
||||||
ChartConstructor.defaults.borderColor =
|
if (this.externalHidden) {
|
||||||
computedStyles.getPropertyValue("--divider-color");
|
const isSelected = params.selected[params.name];
|
||||||
ChartConstructor.defaults.color = computedStyles.getPropertyValue(
|
if (isSelected) {
|
||||||
"--secondary-text-color"
|
fireEvent(this, "dataset-unhidden", { name: params.name });
|
||||||
);
|
} else {
|
||||||
ChartConstructor.defaults.font.family =
|
fireEvent(this, "dataset-hidden", { name: params.name });
|
||||||
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.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 {
|
} finally {
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createOptions(): ChartOptions {
|
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
|
||||||
const modifierKey = isMac ? "meta" : "ctrl";
|
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 {
|
return {
|
||||||
maintainAspectRatio: false,
|
id: "dataZoom",
|
||||||
animation: {
|
type: "inside",
|
||||||
duration: 500,
|
orient: "horizontal",
|
||||||
},
|
filterMode: "none",
|
||||||
...this.options,
|
moveOnMouseMove: this._isZoomed,
|
||||||
plugins: {
|
preventDefaultMouseMove: this._isZoomed,
|
||||||
...this.options?.plugins,
|
zoomLock: !this._isTouchDevice && !this._modifierPressed,
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createPlugins() {
|
private _createOptions(): ECOption {
|
||||||
return [
|
const darkMode = this.hass.themes?.darkMode ?? false;
|
||||||
...(this.plugins || []),
|
const xAxis = Array.isArray(this.options?.xAxis)
|
||||||
{
|
? this.options?.xAxis
|
||||||
id: "resizeHook",
|
: [this.options?.xAxis];
|
||||||
resize: (chart: Chart) => {
|
const yAxis = Array.isArray(this.options?.yAxis)
|
||||||
if (!this.height) {
|
? this.options?.yAxis
|
||||||
// lock the height
|
: [this.options?.yAxis];
|
||||||
// this removes empty space below the chart
|
// we should create our own theme but this is a quick fix for now
|
||||||
this.height = chart.height;
|
const splitLineStyle = darkMode ? { color: "#333" } : {};
|
||||||
}
|
|
||||||
},
|
const options = {
|
||||||
legend: {
|
animation: !this._reducedMotion,
|
||||||
...this.options?.plugins?.legend,
|
darkMode,
|
||||||
display: false,
|
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() {
|
private _getDefaultHeight() {
|
||||||
return this.clientWidth / 2;
|
return Math.max(this.clientWidth / 2, 400);
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleZoomReset() {
|
private _handleZoomReset() {
|
||||||
this.chart?.resetZoom();
|
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleScroll = () => {
|
private _handleWheel(e: WheelEvent) {
|
||||||
this._tooltip = undefined;
|
// 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`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
@ -533,124 +301,11 @@ export class HaChartBase extends LitElement {
|
|||||||
}
|
}
|
||||||
.chart-container {
|
.chart-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
max-height: var(--chart-max-height, 400px);
|
max-height: var(--chart-max-height, 400px);
|
||||||
}
|
}
|
||||||
canvas.not-zoomed {
|
.chart {
|
||||||
/* 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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
height: 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);
|
|
||||||
}
|
}
|
||||||
.zoom-reset {
|
.zoom-reset {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -670,7 +325,7 @@ declare global {
|
|||||||
"ha-chart-base": HaChartBase;
|
"ha-chart-base": HaChartBase;
|
||||||
}
|
}
|
||||||
interface HASSDomEvents {
|
interface HASSDomEvents {
|
||||||
"dataset-hidden": { index: number };
|
"dataset-hidden": { name: string };
|
||||||
"dataset-unhidden": { index: number };
|
"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 { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import { measureTextWidth } from "../../util/text";
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
@ -68,15 +69,12 @@ export class HaSankeyChart extends LitElement {
|
|||||||
|
|
||||||
private _statePerPixel = 0;
|
private _statePerPixel = 0;
|
||||||
|
|
||||||
private _textMeasureCanvas?: HTMLCanvasElement;
|
|
||||||
|
|
||||||
private _sizeController = new ResizeController(this, {
|
private _sizeController = new ResizeController(this, {
|
||||||
callback: (entries) => entries[0]?.contentRect,
|
callback: (entries) => entries[0]?.contentRect,
|
||||||
});
|
});
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this._textMeasureCanvas = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
willUpdate() {
|
willUpdate() {
|
||||||
@ -477,7 +475,7 @@ export class HaSankeyChart extends LitElement {
|
|||||||
(node) =>
|
(node) =>
|
||||||
NODE_WIDTH +
|
NODE_WIDTH +
|
||||||
TEXT_PADDING +
|
TEXT_PADDING +
|
||||||
(node.label ? this._getTextWidth(node.label) : 0)
|
(node.label ? measureTextWidth(node.label, FONT_SIZE) : 0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
@ -492,18 +490,6 @@ export class HaSankeyChart extends LitElement {
|
|||||||
: fullSize / nodesPerSection.length;
|
: 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 {
|
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
|
||||||
// reduce the label font size so the longest word fits on one line
|
// reduce the label font size so the longest word fits on one line
|
||||||
const longestWord = label
|
const longestWord = label
|
||||||
@ -513,7 +499,7 @@ export class HaSankeyChart extends LitElement {
|
|||||||
longest.length > current.length ? longest : current,
|
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);
|
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 type { PropertyValues } from "lit";
|
||||||
import { html, LitElement } from "lit";
|
import { html, LitElement } from "lit";
|
||||||
import { property, state } from "lit/decorators";
|
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 { getGraphColorByIndex } from "../../common/color/colors";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
|
||||||
import { computeRTL } from "../../common/util/compute_rtl";
|
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 { LineChartEntity, LineChartState } from "../../data/history";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
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 safeParseFloat = (value) => {
|
||||||
const parsed = parseFloat(value);
|
const parsed = parseFloat(value);
|
||||||
@ -54,15 +61,17 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
|
@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[] = [];
|
@state() private _entityIds: string[] = [];
|
||||||
|
|
||||||
private _datasetToDataIndex: number[] = [];
|
private _datasetToDataIndex: number[] = [];
|
||||||
|
|
||||||
@state() private _chartOptions?: ChartOptions;
|
@state() private _chartOptions?: ECOption;
|
||||||
|
|
||||||
@state() private _yWidth = 0;
|
@state() private _yWidth = 25;
|
||||||
|
|
||||||
private _chartTime: Date = new Date();
|
private _chartTime: Date = new Date();
|
||||||
|
|
||||||
@ -72,171 +81,54 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData}
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.paddingYAxis=${this.paddingYAxis - this._yWidth}
|
.height=${this.height}
|
||||||
chart-type="line"
|
style=${styleMap({ height: this.height })}
|
||||||
></ha-chart-base>
|
></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) {
|
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 (
|
if (
|
||||||
changedProps.has("data") ||
|
changedProps.has("data") ||
|
||||||
changedProps.has("startTime") ||
|
changedProps.has("startTime") ||
|
||||||
@ -248,13 +140,130 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
// so the X axis grows even if there is no new data
|
// so the X axis grows even if there is no new data
|
||||||
this._generateData();
|
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() {
|
private _generateData() {
|
||||||
let colorIndex = 0;
|
let colorIndex = 0;
|
||||||
const computedStyles = getComputedStyle(this);
|
const computedStyles = getComputedStyle(this);
|
||||||
const entityStates = this.data;
|
const entityStates = this.data;
|
||||||
const datasets: ChartDataset<"line">[] = [];
|
const datasets: LineSeriesOption[] = [];
|
||||||
const entityIds: string[] = [];
|
const entityIds: string[] = [];
|
||||||
const datasetToDataIndex: number[] = [];
|
const datasetToDataIndex: number[] = [];
|
||||||
if (entityStates.length === 0) {
|
if (entityStates.length === 0) {
|
||||||
@ -270,7 +279,7 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
// array containing [value1, value2, etc]
|
// array containing [value1, value2, etc]
|
||||||
let prevValues: any[] | null = null;
|
let prevValues: any[] | null = null;
|
||||||
|
|
||||||
const data: ChartDataset<"line">[] = [];
|
const data: LineSeriesOption[] = [];
|
||||||
|
|
||||||
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
||||||
if (!datavalues) return;
|
if (!datavalues) return;
|
||||||
@ -287,9 +296,9 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
// to the chart for the previous value. Otherwise the gap will
|
// to the chart for the previous value. Otherwise the gap will
|
||||||
// be too big. It will go from the start of the previous data
|
// be too big. It will go from the start of the previous data
|
||||||
// value until the start of the next data value.
|
// 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;
|
prevValues = datavalues;
|
||||||
};
|
};
|
||||||
@ -300,13 +309,25 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
colorIndex++;
|
colorIndex++;
|
||||||
}
|
}
|
||||||
data.push({
|
data.push({
|
||||||
label: nameY,
|
id: nameY,
|
||||||
fill: fill ? "origin" : false,
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: color + "7F",
|
|
||||||
stepped: "before",
|
|
||||||
pointRadius: 0,
|
|
||||||
data: [],
|
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);
|
entityIds.push(states.entity_id);
|
||||||
datasetToDataIndex.push(dataIdx);
|
datasetToDataIndex.push(dataIdx);
|
||||||
@ -575,9 +596,7 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
Array.prototype.push.apply(datasets, data);
|
Array.prototype.push.apply(datasets, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._chartData = {
|
this._chartData = datasets;
|
||||||
datasets,
|
|
||||||
};
|
|
||||||
this._entityIds = entityIds;
|
this._entityIds = entityIds;
|
||||||
this._datasetToDataIndex = datasetToDataIndex;
|
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 type { PropertyValues } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
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 { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||||
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
|
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 { computeRTL } from "../../common/util/compute_rtl";
|
||||||
import type { TimelineEntity } from "../../data/history";
|
import type { TimelineEntity } from "../../data/history";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||||
import type { TimeLineData } from "./timeline-chart/const";
|
import { computeTimelineColor } from "./timeline-color";
|
||||||
import { computeTimelineColor } from "./timeline-chart/timeline-color";
|
import type { ECOption } from "../../resources/echarts";
|
||||||
import { clickIsTouch } from "./click_is_touch";
|
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")
|
@customElement("state-history-chart-timeline")
|
||||||
export class StateHistoryChartTimeline extends LitElement {
|
export class StateHistoryChartTimeline extends LitElement {
|
||||||
@ -44,9 +52,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false, type: Number }) public chartIndex?;
|
@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;
|
@state() private _yWidth = 0;
|
||||||
|
|
||||||
@ -56,15 +64,95 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.height=${this.data.length * 30 + 30}
|
.height=${`${this.data.length * 30 + 30}px`}
|
||||||
.paddingYAxis=${this.paddingYAxis - this._yWidth}
|
.data=${this._chartData}
|
||||||
chart-type="timeline"
|
|
||||||
></ha-chart-base>
|
></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) {
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
if (!this.hasUpdated) {
|
if (!this.hasUpdated) {
|
||||||
this._createOptions();
|
this._createOptions();
|
||||||
@ -85,7 +173,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
if (
|
if (
|
||||||
changedProps.has("startTime") ||
|
changedProps.has("startTime") ||
|
||||||
changedProps.has("endTime") ||
|
changedProps.has("endTime") ||
|
||||||
changedProps.has("showNames")
|
changedProps.has("showNames") ||
|
||||||
|
changedProps.has("paddingYAxis")
|
||||||
) {
|
) {
|
||||||
this._createOptions();
|
this._createOptions();
|
||||||
}
|
}
|
||||||
@ -93,144 +182,68 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
|
|
||||||
private _createOptions() {
|
private _createOptions() {
|
||||||
const narrow = this.narrow;
|
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 = {
|
this._chartOptions = {
|
||||||
maintainAspectRatio: false,
|
xAxis: {
|
||||||
parsing: false,
|
type: "time",
|
||||||
scales: {
|
min: this.startTime,
|
||||||
x: {
|
max: this.endTime,
|
||||||
type: "time",
|
axisLabel: getTimeAxisLabelConfig(
|
||||||
position: "bottom",
|
this.hass.locale,
|
||||||
adapters: {
|
this.hass.config,
|
||||||
date: {
|
dayDifference
|
||||||
locale: this.hass.locale,
|
),
|
||||||
config: this.hass.config,
|
minInterval:
|
||||||
},
|
dayDifference >= 89 // quarter
|
||||||
},
|
? 28 * 3600 * 24 * 1000
|
||||||
min: this.startTime,
|
: dayDifference > 2
|
||||||
suggestedMax: this.endTime,
|
? 3600 * 24 * 1000
|
||||||
ticks: {
|
: undefined,
|
||||||
autoSkip: true,
|
},
|
||||||
maxRotation: 0,
|
yAxis: {
|
||||||
sampleSize: 5,
|
type: "category",
|
||||||
autoSkipPadding: 20,
|
inverse: true,
|
||||||
major: {
|
position: rtl ? "right" : "left",
|
||||||
enabled: true,
|
axisTick: {
|
||||||
},
|
show: false,
|
||||||
font: (context) =>
|
|
||||||
context.tick && context.tick.major
|
|
||||||
? ({ weight: "bold" } as any)
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
offset: false,
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
tooltipFormat: "datetimeseconds",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
y: {
|
axisLine: {
|
||||||
type: "category",
|
show: false,
|
||||||
barThickness: 20,
|
},
|
||||||
offset: true,
|
axisLabel: {
|
||||||
grid: {
|
show: showNames,
|
||||||
display: false,
|
width: labelWidth,
|
||||||
drawBorder: false,
|
overflow: "truncate",
|
||||||
drawTicks: false,
|
margin: 5,
|
||||||
},
|
// @ts-ignore this is a valid option
|
||||||
ticks: {
|
formatter: (label: string) => {
|
||||||
display: this.chunked || this.showNames,
|
const width = Math.min(measureTextWidth(label, 12) + 5, labelWidth);
|
||||||
},
|
if (width > this._yWidth) {
|
||||||
afterSetDimensions: (y) => {
|
this._yWidth = width;
|
||||||
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);
|
|
||||||
fireEvent(this, "y-width-changed", {
|
fireEvent(this, "y-width-changed", {
|
||||||
value: this._yWidth,
|
value: this._yWidth,
|
||||||
chartIndex: this.chartIndex,
|
chartIndex: this.chartIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return label;
|
||||||
},
|
},
|
||||||
position: computeRTL(this.hass) ? "right" : "left",
|
hideOverlap: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
grid: {
|
||||||
tooltip: {
|
top: 10,
|
||||||
mode: "nearest",
|
bottom: 30,
|
||||||
callbacks: {
|
left: rtl ? 1 : labelWidth,
|
||||||
title: (context) =>
|
right: rtl ? labelWidth : 1,
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// @ts-expect-error
|
tooltip: {
|
||||||
locale: numberFormatToLocale(this.hass.locale),
|
appendTo: document.body,
|
||||||
onClick: (e: any) => {
|
formatter: this._renderTooltip,
|
||||||
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
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -246,8 +259,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
this._chartTime = new Date();
|
this._chartTime = new Date();
|
||||||
const startTime = this.startTime;
|
const startTime = this.startTime;
|
||||||
const endTime = this.endTime;
|
const endTime = this.endTime;
|
||||||
const labels: string[] = [];
|
const datasets: CustomSeriesOption[] = [];
|
||||||
const datasets: ChartDataset<"timeline">[] = [];
|
|
||||||
const names = this.names || {};
|
const names = this.names || {};
|
||||||
// stateHistory is a list of lists of sorted state objects
|
// stateHistory is a list of lists of sorted state objects
|
||||||
stateHistory.forEach((stateInfo) => {
|
stateHistory.forEach((stateInfo) => {
|
||||||
@ -258,7 +270,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
const entityDisplay: string =
|
const entityDisplay: string =
|
||||||
names[stateInfo.entity_id] || stateInfo.name;
|
names[stateInfo.entity_id] || stateInfo.name;
|
||||||
|
|
||||||
const dataRow: TimeLineData[] = [];
|
const dataRow: unknown[] = [];
|
||||||
stateInfo.data.forEach((entityState) => {
|
stateInfo.data.forEach((entityState) => {
|
||||||
let newState: string | null = entityState.state;
|
let newState: string | null = entityState.state;
|
||||||
const timeStamp = new Date(entityState.last_changed);
|
const timeStamp = new Date(entityState.last_changed);
|
||||||
@ -277,15 +289,23 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
} else if (newState !== prevState) {
|
} else if (newState !== prevState) {
|
||||||
newLastChanged = new Date(entityState.last_changed);
|
newLastChanged = new Date(entityState.last_changed);
|
||||||
|
|
||||||
|
const color = computeTimelineColor(
|
||||||
|
prevState,
|
||||||
|
computedStyles,
|
||||||
|
this.hass.states[stateInfo.entity_id]
|
||||||
|
);
|
||||||
dataRow.push({
|
dataRow.push({
|
||||||
start: prevLastChanged,
|
value: [
|
||||||
end: newLastChanged,
|
entityDisplay,
|
||||||
label: locState,
|
prevLastChanged,
|
||||||
color: computeTimelineColor(
|
newLastChanged,
|
||||||
prevState,
|
locState,
|
||||||
computedStyles,
|
color,
|
||||||
this.hass.states[stateInfo.entity_id]
|
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||||
),
|
],
|
||||||
|
itemStyle: {
|
||||||
|
color,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
prevState = newState;
|
prevState = newState;
|
||||||
@ -295,28 +315,40 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (prevState !== null) {
|
if (prevState !== null) {
|
||||||
|
const color = computeTimelineColor(
|
||||||
|
prevState,
|
||||||
|
computedStyles,
|
||||||
|
this.hass.states[stateInfo.entity_id]
|
||||||
|
);
|
||||||
dataRow.push({
|
dataRow.push({
|
||||||
start: prevLastChanged,
|
value: [
|
||||||
end: endTime,
|
entityDisplay,
|
||||||
label: locState,
|
prevLastChanged,
|
||||||
color: computeTimelineColor(
|
endTime,
|
||||||
prevState,
|
locState,
|
||||||
computedStyles,
|
color,
|
||||||
this.hass.states[stateInfo.entity_id]
|
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||||
),
|
],
|
||||||
|
itemStyle: {
|
||||||
|
color,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
datasets.push({
|
datasets.push({
|
||||||
data: dataRow,
|
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 = {
|
this._chartData = datasets;
|
||||||
labels: labels,
|
|
||||||
datasets: datasets,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
|
|||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||||
|
import { styleMap } from "lit/directives/style-map";
|
||||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||||
import type {
|
import type {
|
||||||
@ -69,6 +70,8 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
|
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
|
||||||
|
|
||||||
|
@property({ type: String }) public height?: string;
|
||||||
|
|
||||||
private _computedStartTime!: Date;
|
private _computedStartTime!: Date;
|
||||||
|
|
||||||
private _computedEndTime!: Date;
|
private _computedEndTime!: Date;
|
||||||
@ -133,7 +136,10 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
if (!Array.isArray(item)) {
|
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
|
<state-history-chart-line
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.unit=${item.unit}
|
.unit=${item.unit}
|
||||||
@ -151,6 +157,7 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
.maxYAxis=${this.maxYAxis}
|
.maxYAxis=${this.maxYAxis}
|
||||||
.fitYData=${this.fitYData}
|
.fitYData=${this.fitYData}
|
||||||
@y-width-changed=${this._yWidthChanged}
|
@y-width-changed=${this._yWidthChanged}
|
||||||
|
.height=${this.height}
|
||||||
></state-history-chart-line>
|
></state-history-chart-line>
|
||||||
</div> `;
|
</div> `;
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,15 @@
|
|||||||
import type {
|
|
||||||
ChartData,
|
|
||||||
ChartDataset,
|
|
||||||
ChartOptions,
|
|
||||||
ChartType,
|
|
||||||
} from "chart.js";
|
|
||||||
import type { PropertyValues, TemplateResult } from "lit";
|
import type { PropertyValues, TemplateResult } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
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 { getGraphColorByIndex } from "../../common/color/colors";
|
||||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
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 {
|
import type {
|
||||||
Statistics,
|
Statistics,
|
||||||
StatisticsMetaData,
|
StatisticsMetaData,
|
||||||
@ -25,13 +19,18 @@ import {
|
|||||||
getDisplayUnit,
|
getDisplayUnit,
|
||||||
getStatisticLabel,
|
getStatisticLabel,
|
||||||
getStatisticMetadata,
|
getStatisticMetadata,
|
||||||
isExternalStatistic,
|
|
||||||
statisticsHaveType,
|
statisticsHaveType,
|
||||||
} from "../../data/recorder";
|
} from "../../data/recorder";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import "./ha-chart-base";
|
import "./ha-chart-base";
|
||||||
import type { ChartDatasetExtra } from "./ha-chart-base";
|
import { computeRTL } from "../../common/util/compute_rtl";
|
||||||
import { clickIsTouch } from "./click_is_touch";
|
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> = {
|
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||||
mean: "mean",
|
mean: "mean",
|
||||||
@ -62,7 +61,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false, type: Array })
|
||||||
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
|
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;
|
@property({ attribute: false, type: Number }) public minYAxis?: number;
|
||||||
|
|
||||||
@ -84,15 +83,18 @@ export class StatisticsChart extends LitElement {
|
|||||||
|
|
||||||
@property() public period?: string;
|
@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 _statisticIds: string[] = [];
|
||||||
|
|
||||||
@state() private _chartOptions?: ChartOptions;
|
@state() private _chartOptions?: ECOption;
|
||||||
|
|
||||||
@state() private _hiddenStats = new Set<string>();
|
|
||||||
|
|
||||||
private _computedStyle?: CSSStyleDeclaration;
|
private _computedStyle?: CSSStyleDeclaration;
|
||||||
|
|
||||||
@ -101,8 +103,13 @@ export class StatisticsChart extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public willUpdate(changedProps: PropertyValues) {
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
if (changedProps.has("legendMode")) {
|
if (
|
||||||
this._hiddenStats.clear();
|
changedProps.has("statisticsData") ||
|
||||||
|
changedProps.has("statTypes") ||
|
||||||
|
changedProps.has("chartType") ||
|
||||||
|
changedProps.has("hideLegend")
|
||||||
|
) {
|
||||||
|
this._generateData();
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!this.hasUpdated ||
|
!this.hasUpdated ||
|
||||||
@ -117,15 +124,6 @@ export class StatisticsChart extends LitElement {
|
|||||||
) {
|
) {
|
||||||
this._createOptions();
|
this._createOptions();
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
changedProps.has("statisticsData") ||
|
|
||||||
changedProps.has("statTypes") ||
|
|
||||||
changedProps.has("chartType") ||
|
|
||||||
changedProps.has("hideLegend") ||
|
|
||||||
changedProps.has("_hiddenStats")
|
|
||||||
) {
|
|
||||||
this._generateData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public firstUpdated() {
|
public firstUpdated() {
|
||||||
@ -157,146 +155,105 @@ export class StatisticsChart extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
external-hidden
|
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData}
|
||||||
.extraData=${this._chartDatasetExtra}
|
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.chartType=${this.chartType}
|
.height=${this.height}
|
||||||
@dataset-hidden=${this._datasetHidden}
|
style=${styleMap({ height: this.height })}
|
||||||
@dataset-unhidden=${this._datasetUnhidden}
|
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _datasetHidden(ev) {
|
private _renderTooltip(params: any) {
|
||||||
ev.stopPropagation();
|
return params
|
||||||
this._hiddenStats.add(this._statisticIds[ev.detail.index]);
|
.map((param, index: number) => {
|
||||||
this.requestUpdate("_hiddenStats");
|
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) {
|
const time =
|
||||||
ev.stopPropagation();
|
index === 0
|
||||||
this._hiddenStats.delete(this._statisticIds[ev.detail.index]);
|
? formatDateTimeWithSeconds(
|
||||||
this.requestUpdate("_hiddenStats");
|
new Date(param.value[0]),
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
getNumberFormatOptions(
|
this.hass.config
|
||||||
undefined,
|
) + "<br>"
|
||||||
this.hass.entities[this._statisticIds[context.datasetIndex]]
|
: "";
|
||||||
)
|
return `${time}${param.marker} ${param.seriesName}: ${value}
|
||||||
)} ${
|
`;
|
||||||
// @ts-ignore
|
})
|
||||||
context.dataset.unit || ""
|
.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: {
|
splitLine: {
|
||||||
propagate: true,
|
show: true,
|
||||||
|
lineStyle: splitLineStyle,
|
||||||
},
|
},
|
||||||
legend: {
|
minInterval:
|
||||||
display: !this.hideLegend,
|
dayDifference >= 89 // quarter
|
||||||
labels: {
|
? 28 * 3600 * 24 * 1000
|
||||||
usePointStyle: true,
|
: 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: {
|
legend: {
|
||||||
line: {
|
show: !this.hideLegend,
|
||||||
tension: 0.4,
|
icon: "circle",
|
||||||
cubicInterpolationMode: "monotone",
|
padding: [20, 0],
|
||||||
borderWidth: 1.5,
|
data: this._legendData,
|
||||||
},
|
|
||||||
bar: { borderWidth: 1.5, borderRadius: 4 },
|
|
||||||
point: {
|
|
||||||
hitRadius: 50,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// @ts-expect-error
|
grid: {
|
||||||
locale: numberFormatToLocale(this.hass.locale),
|
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
|
||||||
onClick: (e: any) => {
|
left: 20,
|
||||||
if (!this.clickForMoreInfo || clickIsTouch(e)) {
|
right: 1,
|
||||||
return;
|
bottom: 0,
|
||||||
}
|
containLabel: true,
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
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;
|
let colorIndex = 0;
|
||||||
const statisticsData = Object.entries(this.statisticsData);
|
const statisticsData = Object.entries(this.statisticsData);
|
||||||
const totalDataSets: ChartDataset<"line">[] = [];
|
const totalDataSets: LineSeriesOption[] = [];
|
||||||
const totalDatasetExtras: ChartDatasetExtra[] = [];
|
const legendData: string[] = [];
|
||||||
const statisticIds: string[] = [];
|
const statisticIds: string[] = [];
|
||||||
let endTime: Date;
|
let endTime: Date;
|
||||||
|
|
||||||
@ -372,19 +329,19 @@ export class StatisticsChart extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// array containing [value1, value2, etc]
|
// array containing [value1, value2, etc]
|
||||||
let prevValues: (number | null)[] | null = null;
|
let prevValues: (number | null)[][] | null = null;
|
||||||
let prevEndTime: Date | undefined;
|
let prevEndTime: Date | undefined;
|
||||||
|
|
||||||
// The datasets for the current statistic
|
// The datasets for the current statistic
|
||||||
const statDataSets: ChartDataset<"line">[] = [];
|
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||||
const statDatasetExtras: ChartDatasetExtra[] = [];
|
const statLegendData: string[] = [];
|
||||||
|
|
||||||
const pushData = (
|
const pushData = (
|
||||||
start: Date,
|
start: Date,
|
||||||
end: Date,
|
end: Date,
|
||||||
dataValues: (number | null)[] | null
|
dataValues: (number | null)[][]
|
||||||
) => {
|
) => {
|
||||||
if (!dataValues) return;
|
if (!dataValues.length) return;
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
// Drop data points that are after the requested endTime. This could happen if
|
// 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.
|
// 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,
|
// 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.
|
// 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]! });
|
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||||
// @ts-expect-error
|
d.data!.push([prevEndTime, null]);
|
||||||
d.data.push({ x: prevEndTime.getTime(), y: null });
|
|
||||||
}
|
}
|
||||||
d.data.push({ x: start.getTime(), y: dataValues[i]! });
|
d.data!.push([start, ...dataValues[i]!]);
|
||||||
});
|
});
|
||||||
prevValues = dataValues;
|
prevValues = dataValues;
|
||||||
prevEndTime = end;
|
prevEndTime = end;
|
||||||
@ -438,23 +394,25 @@ export class StatisticsChart extends LitElement {
|
|||||||
})
|
})
|
||||||
: this.statTypes;
|
: this.statTypes;
|
||||||
|
|
||||||
let displayed_legend = false;
|
let displayedLegend = false;
|
||||||
sortedTypes.forEach((type) => {
|
sortedTypes.forEach((type) => {
|
||||||
if (statisticsHaveType(stats, type)) {
|
if (statisticsHaveType(stats, type)) {
|
||||||
const band = drawBands && (type === "min" || type === "max");
|
const band = drawBands && (type === "min" || type === "max");
|
||||||
if (!this.hideLegend) {
|
if (!this.hideLegend) {
|
||||||
const show_legend = hasMean
|
const showLegend = hasMean
|
||||||
? type === "mean"
|
? type === "mean"
|
||||||
: displayed_legend === false;
|
: displayedLegend === false;
|
||||||
statDatasetExtras.push({
|
if (showLegend) {
|
||||||
legend_label: name,
|
statLegendData.push(name);
|
||||||
show_legend,
|
}
|
||||||
});
|
displayedLegend = displayedLegend || showLegend;
|
||||||
displayed_legend = displayed_legend || show_legend;
|
|
||||||
}
|
}
|
||||||
statTypes.push(type);
|
statTypes.push(type);
|
||||||
statDataSets.push({
|
const series: LineSeriesOption | BarSeriesOption = {
|
||||||
label: name
|
id: `${statistic_id}-${type}`,
|
||||||
|
type: this.chartType,
|
||||||
|
data: [],
|
||||||
|
name: name
|
||||||
? `${name} (${this.hass.localize(
|
? `${name} (${this.hass.localize(
|
||||||
`ui.components.statistics_charts.statistic_types.${type}`
|
`ui.components.statistics_charts.statistic_types.${type}`
|
||||||
)})
|
)})
|
||||||
@ -462,25 +420,26 @@ export class StatisticsChart extends LitElement {
|
|||||||
: this.hass.localize(
|
: this.hass.localize(
|
||||||
`ui.components.statistics_charts.statistic_types.${type}`
|
`ui.components.statistics_charts.statistic_types.${type}`
|
||||||
),
|
),
|
||||||
fill: drawBands
|
symbol: "circle",
|
||||||
? type === "min" && hasMean
|
symbolSize: 0,
|
||||||
? "+1"
|
lineStyle: {
|
||||||
: type === "max"
|
width: 1.5,
|
||||||
? "-1"
|
},
|
||||||
: false
|
color: band && hasMean ? color + "3F" : color,
|
||||||
: false,
|
};
|
||||||
borderColor:
|
if (band) {
|
||||||
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color,
|
series.stack = "band";
|
||||||
backgroundColor: band ? color + "3F" : color + "7F",
|
(series as LineSeriesOption).symbol = "none";
|
||||||
pointRadius: 0,
|
(series as LineSeriesOption).lineStyle = {
|
||||||
hidden: !this.hideLegend
|
opacity: 0,
|
||||||
? this._hiddenStats.has(statistic_id)
|
};
|
||||||
: false,
|
if (drawBands && type === "max") {
|
||||||
data: [],
|
(series as LineSeriesOption).areaStyle = {
|
||||||
// @ts-ignore
|
color: color + "3F",
|
||||||
unit: meta?.unit_of_measurement,
|
};
|
||||||
band,
|
}
|
||||||
});
|
}
|
||||||
|
statDataSets.push(series);
|
||||||
statisticIds.push(statistic_id);
|
statisticIds.push(statistic_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -494,37 +453,39 @@ export class StatisticsChart extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevDate = startDate;
|
prevDate = startDate;
|
||||||
const dataValues: (number | null)[] = [];
|
const dataValues: (number | null)[][] = [];
|
||||||
statTypes.forEach((type) => {
|
statTypes.forEach((type) => {
|
||||||
let val: number | null | undefined;
|
const val: (number | null)[] = [];
|
||||||
if (type === "sum") {
|
if (type === "sum") {
|
||||||
if (firstSum === null || firstSum === undefined) {
|
if (firstSum === null || firstSum === undefined) {
|
||||||
val = 0;
|
val.push(0);
|
||||||
firstSum = stat.sum;
|
firstSum = stat.sum;
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
val = stat[type];
|
val.push(stat[type] ?? null);
|
||||||
}
|
}
|
||||||
dataValues.push(val ?? null);
|
dataValues.push(val);
|
||||||
});
|
});
|
||||||
pushData(startDate, new Date(stat.end), dataValues);
|
pushData(startDate, new Date(stat.end), dataValues);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Concat two arrays
|
// Concat two arrays
|
||||||
Array.prototype.push.apply(totalDataSets, statDataSets);
|
Array.prototype.push.apply(totalDataSets, statDataSets);
|
||||||
Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras);
|
Array.prototype.push.apply(legendData, statLegendData);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unit) {
|
if (unit) {
|
||||||
this._createOptions(unit);
|
this.unit = unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._chartData = {
|
this._chartData = totalDataSets;
|
||||||
datasets: totalDataSets,
|
this._legendData = legendData;
|
||||||
};
|
|
||||||
this._chartDatasetExtra = totalDatasetExtras;
|
|
||||||
this._statisticIds = statisticIds;
|
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 type { HassEntity } from "home-assistant-js-websocket";
|
||||||
import { getGraphColorByIndex } from "../../../common/color/colors";
|
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||||
import { hex2rgb, lab2hex, rgb2lab } from "../../../common/color/convert-color";
|
import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color";
|
||||||
import { labBrighten } from "../../../common/color/lab";
|
import { labBrighten } from "../../common/color/lab";
|
||||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
import { computeDomain } from "../../common/entity/compute_domain";
|
||||||
import { stateColorProperties } from "../../../common/entity/state_color";
|
import { stateColorProperties } from "../../common/entity/state_color";
|
||||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
|
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
|
||||||
import { computeCssValue } from "../../../resources/css-variables";
|
import { computeCssValue } from "../../resources/css-variables";
|
||||||
|
|
||||||
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
|
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
|
||||||
media_player: {
|
media_player: {
|
@ -1,13 +1,13 @@
|
|||||||
import "@material/mwc-list/mwc-list";
|
import "@material/mwc-list/mwc-list";
|
||||||
import "@material/mwc-list/mwc-list-item";
|
import "@material/mwc-list/mwc-list-item";
|
||||||
import { mdiPower } from "@mdi/js";
|
import { mdiPower } from "@mdi/js";
|
||||||
import type { ChartOptions } from "chart.js";
|
|
||||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
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 { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
import { numberFormatToLocale } from "../../../common/number/format_number";
|
|
||||||
import { round } from "../../../common/number/round";
|
import { round } from "../../../common/number/round";
|
||||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||||
import "../../../components/buttons/ha-progress-button";
|
import "../../../components/buttons/ha-progress-button";
|
||||||
@ -38,16 +38,22 @@ import type { HomeAssistant } from "../../../types";
|
|||||||
import { hardwareBrandsUrl } from "../../../util/brands-url";
|
import { hardwareBrandsUrl } from "../../../util/brands-url";
|
||||||
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
|
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
|
||||||
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
||||||
|
import type { ECOption } from "../../../resources/echarts";
|
||||||
|
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
|
||||||
|
|
||||||
const DATASAMPLES = 60;
|
const DATASAMPLES = 60;
|
||||||
|
|
||||||
const DATA_SET_CONFIG = {
|
const DATA_SET_CONFIG: SeriesOption = {
|
||||||
fill: "origin",
|
type: "line",
|
||||||
borderColor: DEFAULT_PRIMARY_COLOR,
|
color: DEFAULT_PRIMARY_COLOR,
|
||||||
backgroundColor: DEFAULT_PRIMARY_COLOR + "2B",
|
areaStyle: {
|
||||||
pointRadius: 0,
|
color: DEFAULT_PRIMARY_COLOR + "2B",
|
||||||
lineTension: 0.2,
|
},
|
||||||
borderWidth: 1,
|
symbolSize: 0,
|
||||||
|
lineStyle: {
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
smooth: 0.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
@customElement("ha-config-hardware")
|
@customElement("ha-config-hardware")
|
||||||
@ -62,15 +68,15 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
|
|
||||||
@state() private _hardwareInfo?: HardwareInfo;
|
@state() private _hardwareInfo?: HardwareInfo;
|
||||||
|
|
||||||
@state() private _chartOptions?: ChartOptions;
|
@state() private _chartOptions?: ECOption;
|
||||||
|
|
||||||
@state() private _systemStatusData?: SystemStatusStreamMessage;
|
@state() private _systemStatusData?: SystemStatusStreamMessage;
|
||||||
|
|
||||||
@state() private _configEntries?: Record<string, ConfigEntry>;
|
@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>)[] {
|
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||||
const subs = [
|
const subs = [
|
||||||
@ -121,14 +127,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
this._memoryEntries.shift();
|
this._memoryEntries.shift();
|
||||||
this._cpuEntries.shift();
|
this._cpuEntries.shift();
|
||||||
|
|
||||||
this._memoryEntries.push({
|
this._memoryEntries.push([
|
||||||
x: new Date(message.timestamp).getTime(),
|
new Date(message.timestamp).getTime(),
|
||||||
y: message.memory_used_percent,
|
message.memory_used_percent,
|
||||||
});
|
]);
|
||||||
this._cpuEntries.push({
|
this._cpuEntries.push([
|
||||||
x: new Date(message.timestamp).getTime(),
|
new Date(message.timestamp).getTime(),
|
||||||
y: message.cpu_percent,
|
message.cpu_percent,
|
||||||
});
|
]);
|
||||||
|
|
||||||
this._systemStatusData = message;
|
this._systemStatusData = message;
|
||||||
},
|
},
|
||||||
@ -143,51 +149,44 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(): void {
|
protected willUpdate(): void {
|
||||||
if (!this.hasUpdated) {
|
if (!this.hasUpdated && !this._chartOptions) {
|
||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
responsive: true,
|
xAxis: {
|
||||||
scales: {
|
type: "time",
|
||||||
y: {
|
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
|
||||||
gridLines: {
|
splitLine: {
|
||||||
drawTicks: false,
|
show: true,
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
maxTicksLimit: 7,
|
|
||||||
fontSize: 10,
|
|
||||||
max: 100,
|
|
||||||
min: 0,
|
|
||||||
stepSize: 1,
|
|
||||||
callback: (value) =>
|
|
||||||
value + blankBeforePercent(this.hass.locale) + "%",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
x: {
|
axisLine: {
|
||||||
type: "time",
|
show: false,
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// @ts-expect-error
|
yAxis: {
|
||||||
locale: numberFormatToLocale(this.hass.locale),
|
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++) {
|
for (let i = 0; i < DATASAMPLES; i++) {
|
||||||
const t = new Date(date);
|
const t = new Date(date);
|
||||||
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
|
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
|
||||||
this._memoryEntries.push({ x: t.getTime(), y: null });
|
this._memoryEntries.push([t.getTime(), null]);
|
||||||
this._cpuEntries.push({ x: t.getTime(), y: null });
|
this._cpuEntries.push([t.getTime(), null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,14 +386,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${{
|
.data=${this._getChartData(this._cpuEntries)}
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
...DATA_SET_CONFIG,
|
|
||||||
data: this._cpuEntries,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
</div>
|
</div>
|
||||||
@ -419,14 +411,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${{
|
.data=${this._getChartData(this._memoryEntries)}
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
...DATA_SET_CONFIG,
|
|
||||||
data: this._memoryEntries,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
</div>
|
</div>
|
||||||
@ -482,6 +467,20 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
showRestartDialog(this);
|
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 = [
|
static styles = [
|
||||||
haStyle,
|
haStyle,
|
||||||
css`
|
css`
|
||||||
|
@ -196,6 +196,7 @@ class HaPanelHistory extends LitElement {
|
|||||||
.historyData=${this._mungedStateHistory}
|
.historyData=${this._mungedStateHistory}
|
||||||
.startTime=${this._startDate}
|
.startTime=${this._startDate}
|
||||||
.endTime=${this._endDate}
|
.endTime=${this._endDate}
|
||||||
|
.narrow=${this.narrow}
|
||||||
>
|
>
|
||||||
</state-history-charts>
|
</state-history-charts>
|
||||||
`}
|
`}
|
||||||
|
@ -12,7 +12,7 @@ import { restoreScroll } from "../../common/decorators/restore-scroll";
|
|||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
import { computeDomain } from "../../common/entity/compute_domain";
|
import { computeDomain } from "../../common/entity/compute_domain";
|
||||||
import { navigate } from "../../common/navigate";
|
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/entity/state-badge";
|
||||||
import "../../components/ha-circular-progress";
|
import "../../components/ha-circular-progress";
|
||||||
import "../../components/ha-icon-next";
|
import "../../components/ha-icon-next";
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import type { ChartOptions } from "chart.js";
|
|
||||||
import type { HassConfig } from "home-assistant-js-websocket";
|
import type { HassConfig } from "home-assistant-js-websocket";
|
||||||
import {
|
import { addHours, subHours, differenceInDays } from "date-fns";
|
||||||
addHours,
|
import type {
|
||||||
subHours,
|
BarSeriesOption,
|
||||||
differenceInDays,
|
CallbackDataParams,
|
||||||
differenceInHours,
|
TopLevelFormatterParams,
|
||||||
} from "date-fns";
|
} from "echarts/types/dist/shared";
|
||||||
import type { FrontendLocaleData } from "../../../../../data/translation";
|
import type { FrontendLocaleData } from "../../../../../data/translation";
|
||||||
import {
|
import { formatNumber } from "../../../../../common/number/format_number";
|
||||||
formatNumber,
|
|
||||||
numberFormatToLocale,
|
|
||||||
} from "../../../../../common/number/format_number";
|
|
||||||
import { formatDateVeryShort } from "../../../../../common/datetime/format_date";
|
import { formatDateVeryShort } from "../../../../../common/datetime/format_date";
|
||||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
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 {
|
export function getSuggestedMax(dayDifference: number, end: Date): number {
|
||||||
let suggestedMax = new Date(end);
|
let suggestedMax = new Date(end);
|
||||||
@ -46,127 +44,216 @@ export function getCommonOptions(
|
|||||||
config: HassConfig,
|
config: HassConfig,
|
||||||
unit?: string,
|
unit?: string,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date,
|
||||||
): ChartOptions {
|
formatTotal?: (total: number) => string
|
||||||
|
): ECOption {
|
||||||
const dayDifference = differenceInDays(end, start);
|
const dayDifference = differenceInDays(end, start);
|
||||||
const compare = compareStart !== undefined && compareEnd !== undefined;
|
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 = {
|
const options: ECOption = {
|
||||||
parsing: false,
|
xAxis: {
|
||||||
interaction: {
|
id: "xAxisMain",
|
||||||
mode: "x",
|
type: "time",
|
||||||
},
|
min: start.getTime(),
|
||||||
scales: {
|
max: getSuggestedMax(dayDifference, end),
|
||||||
x: {
|
axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference),
|
||||||
type: "time",
|
axisLine: {
|
||||||
suggestedMin: start.getTime(),
|
show: false,
|
||||||
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),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
y: {
|
splitLine: {
|
||||||
stacked: true,
|
show: true,
|
||||||
type: "linear",
|
},
|
||||||
title: {
|
minInterval:
|
||||||
display: true,
|
dayDifference >= 89 // quarter
|
||||||
text: unit,
|
? 28 * 3600 * 24 * 1000
|
||||||
},
|
: dayDifference > 2
|
||||||
ticks: {
|
? 3600 * 24 * 1000
|
||||||
beginAtZero: true,
|
: undefined,
|
||||||
callback: (value) => formatNumber(Math.abs(value), locale),
|
},
|
||||||
},
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: unit,
|
||||||
|
nameGap: 5,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => formatNumber(Math.abs(value), locale),
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
grid: {
|
||||||
tooltip: {
|
top: 35,
|
||||||
position: "nearest",
|
bottom: 10,
|
||||||
filter: (val) => val.formattedValue !== "0",
|
left: 10,
|
||||||
itemSort: function (a, b) {
|
right: 10,
|
||||||
return b.datasetIndex - a.datasetIndex;
|
containLabel: true,
|
||||||
},
|
},
|
||||||
callbacks: {
|
tooltip: {
|
||||||
title: (datasets) => {
|
trigger: "axis",
|
||||||
if (dayDifference > 0) {
|
formatter: (params: TopLevelFormatterParams): string => {
|
||||||
return datasets[0].label;
|
// 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 `${
|
return [mainItems, compareItems]
|
||||||
compare ? `${formatDateVeryShort(date, locale, config)}: ` : ""
|
.filter((items) => items.length > 0)
|
||||||
}${formatTime(date, locale, config)} – ${formatTime(
|
.map((items) =>
|
||||||
addHours(date, 1),
|
formatTooltip(
|
||||||
locale,
|
items,
|
||||||
config
|
locale,
|
||||||
)}`;
|
config,
|
||||||
},
|
dayDifference,
|
||||||
label: (context) =>
|
compare,
|
||||||
`${context.dataset.label}: ${formatNumber(
|
unit,
|
||||||
context.parsed.y,
|
formatTotal
|
||||||
locale
|
)
|
||||||
)} ${unit}`,
|
)
|
||||||
},
|
.join("<br><br>");
|
||||||
},
|
}
|
||||||
filler: {
|
return formatTooltip(
|
||||||
propagate: false,
|
[params],
|
||||||
},
|
locale,
|
||||||
legend: {
|
config,
|
||||||
display: false,
|
dayDifference,
|
||||||
labels: {
|
compare,
|
||||||
usePointStyle: true,
|
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;
|
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 { endOfToday, startOfToday } from "date-fns";
|
||||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
@ -11,9 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { BarSeriesOption } from "echarts/charts";
|
||||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||||
import { getEnergyColor } from "./common/color";
|
import { getEnergyColor } from "./common/color";
|
||||||
import type { ChartDatasetExtra } from "../../../../components/chart/ha-chart-base";
|
|
||||||
import "../../../../components/ha-card";
|
import "../../../../components/ha-card";
|
||||||
import "../../../../components/chart/ha-chart-base";
|
import "../../../../components/chart/ha-chart-base";
|
||||||
import type {
|
import type {
|
||||||
@ -29,7 +23,6 @@ import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
|
|||||||
import {
|
import {
|
||||||
calculateStatisticSumGrowth,
|
calculateStatisticSumGrowth,
|
||||||
getStatisticLabel,
|
getStatisticLabel,
|
||||||
isExternalStatistic,
|
|
||||||
} from "../../../../data/recorder";
|
} from "../../../../data/recorder";
|
||||||
import type { FrontendLocaleData } from "../../../../data/translation";
|
import type { FrontendLocaleData } from "../../../../data/translation";
|
||||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||||
@ -37,10 +30,13 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergyDevicesDetailGraphCardConfig } from "../types";
|
import type { EnergyDevicesDetailGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
import { hasConfigChanged } from "../../common/has-changed";
|
||||||
import { getCommonOptions } from "./common/energy-chart-options";
|
import {
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
fillDataGapsAndRoundCaps,
|
||||||
|
getCommonOptions,
|
||||||
|
} from "./common/energy-chart-options";
|
||||||
import { storage } from "../../../../common/decorators/storage";
|
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";
|
const UNIT = "kWh";
|
||||||
|
|
||||||
@ -53,9 +49,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergyDevicesDetailGraphCardConfig;
|
@state() private _config?: EnergyDevicesDetailGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = { datasets: [] };
|
@state() private _chartData: BarSeriesOption[] = [];
|
||||||
|
|
||||||
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
|
|
||||||
|
|
||||||
@state() private _data?: EnergyData;
|
@state() private _data?: EnergyData;
|
||||||
|
|
||||||
@ -74,8 +68,6 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
})
|
})
|
||||||
private _hiddenStats: string[] = [];
|
private _hiddenStats: string[] = [];
|
||||||
|
|
||||||
private _untrackedIndex?: number;
|
|
||||||
|
|
||||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||||
|
|
||||||
public hassSubscribe(): UnsubscribeFunc[] {
|
public hassSubscribe(): UnsubscribeFunc[] {
|
||||||
@ -133,7 +125,6 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
external-hidden
|
external-hidden
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData}
|
||||||
.extraData=${this._chartDatasetExtra}
|
|
||||||
.options=${this._createOptions(
|
.options=${this._createOptions(
|
||||||
this._start,
|
this._start,
|
||||||
this._end,
|
this._end,
|
||||||
@ -143,7 +134,6 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
this._compareStart,
|
this._compareStart,
|
||||||
this._compareEnd
|
this._compareEnd
|
||||||
)}
|
)}
|
||||||
chart-type="bar"
|
|
||||||
@dataset-hidden=${this._datasetHidden}
|
@dataset-hidden=${this._datasetHidden}
|
||||||
@dataset-unhidden=${this._datasetUnhidden}
|
@dataset-unhidden=${this._datasetUnhidden}
|
||||||
></ha-chart-base>
|
></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) {
|
private _datasetHidden(ev) {
|
||||||
const hiddenEntity =
|
this._hiddenStats = [...this._hiddenStats, ev.detail.name];
|
||||||
ev.detail.index === this._untrackedIndex
|
|
||||||
? "untracked"
|
|
||||||
: this._data!.prefs.device_consumption[ev.detail.index]
|
|
||||||
.stat_consumption;
|
|
||||||
this._hiddenStats = [...this._hiddenStats, hiddenEntity];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _datasetUnhidden(ev) {
|
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(
|
this._hiddenStats = this._hiddenStats.filter(
|
||||||
(stat) => stat !== hiddenEntity
|
(stat) => stat !== ev.detail.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +167,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
unit?: string,
|
unit?: string,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date
|
||||||
): ChartOptions => {
|
): ECOption => {
|
||||||
const commonOptions = getCommonOptions(
|
const commonOptions = getCommonOptions(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@ -189,44 +175,41 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
config,
|
config,
|
||||||
unit,
|
unit,
|
||||||
compareStart,
|
compareStart,
|
||||||
compareEnd
|
compareEnd,
|
||||||
|
this._formatTotal
|
||||||
);
|
);
|
||||||
|
|
||||||
const options: ChartOptions = {
|
return {
|
||||||
...commonOptions,
|
...commonOptions,
|
||||||
interaction: {
|
legend: {
|
||||||
mode: "nearest",
|
show: true,
|
||||||
|
type: "scroll",
|
||||||
|
animationDurationUpdate: 400,
|
||||||
|
selected: this._hiddenStats.reduce((acc, stat) => {
|
||||||
|
acc[stat] = false;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
icon: "circle",
|
||||||
},
|
},
|
||||||
plugins: {
|
grid: {
|
||||||
...commonOptions.plugins!,
|
bottom: 0,
|
||||||
legend: {
|
left: 5,
|
||||||
display: true,
|
right: 5,
|
||||||
labels: {
|
containLabel: true,
|
||||||
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
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
private _processStatistics() {
|
private _processStatistics() {
|
||||||
const energyData = this._data!;
|
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 data = energyData.stats;
|
||||||
const compareData = energyData.statsCompare;
|
const compareData = energyData.statsCompare;
|
||||||
|
|
||||||
@ -247,21 +230,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
);
|
);
|
||||||
sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]);
|
sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]);
|
||||||
|
|
||||||
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const datasets: BarSeriesOption[] = [];
|
||||||
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 { summedData, compareSummedData } = getSummedData(energyData);
|
const { summedData, compareSummedData } = getSummedData(energyData);
|
||||||
|
|
||||||
@ -277,41 +246,8 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
? computeConsumptionData(summedData, compareSummedData)
|
? computeConsumptionData(summedData, compareSummedData)
|
||||||
: { consumption: undefined, compareConsumption: undefined };
|
: { 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) {
|
if (compareData) {
|
||||||
// Add empty dataset to align the bars
|
const processedCompareData = this._processDataSet(
|
||||||
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(
|
|
||||||
computedStyle,
|
computedStyle,
|
||||||
compareData,
|
compareData,
|
||||||
energyData.statsMetadata,
|
energyData.statsMetadata,
|
||||||
@ -321,33 +257,51 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
);
|
);
|
||||||
|
|
||||||
datasets.push(...processedCompareData);
|
datasets.push(...processedCompareData);
|
||||||
datasetExtras.push(...processedCompareDataExtras);
|
|
||||||
|
|
||||||
if (showUntracked) {
|
if (showUntracked) {
|
||||||
const {
|
const untrackedCompareData = this._processUntracked(
|
||||||
dataset: untrackedCompareData,
|
|
||||||
datasetExtra: untrackedCompareDataExtra,
|
|
||||||
} = this._processUntracked(
|
|
||||||
computedStyle,
|
computedStyle,
|
||||||
processedCompareData,
|
processedCompareData,
|
||||||
consumptionCompareData,
|
consumptionCompareData,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
datasets.push(untrackedCompareData);
|
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;
|
const processedData = this._processDataSet(
|
||||||
this._end = energyData.end || endOfToday();
|
computedStyle,
|
||||||
|
data,
|
||||||
|
energyData.statsMetadata,
|
||||||
|
energyData.prefs.device_consumption,
|
||||||
|
sorted_devices
|
||||||
|
);
|
||||||
|
|
||||||
this._compareStart = energyData.startCompare;
|
datasets.push(...processedData);
|
||||||
this._compareEnd = energyData.endCompare;
|
|
||||||
|
|
||||||
this._chartData = {
|
if (showUntracked) {
|
||||||
datasets,
|
const untrackedData = this._processUntracked(
|
||||||
};
|
computedStyle,
|
||||||
this._chartDatasetExtra = datasetExtras;
|
processedData,
|
||||||
|
consumptionData,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
datasets.push(untrackedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
fillDataGapsAndRoundCaps(datasets);
|
||||||
|
this._chartData = datasets;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processUntracked(
|
private _processUntracked(
|
||||||
@ -355,36 +309,49 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
processedData,
|
processedData,
|
||||||
consumptionData,
|
consumptionData,
|
||||||
compare: boolean
|
compare: boolean
|
||||||
): { dataset; datasetExtra } {
|
): BarSeriesOption {
|
||||||
const totalDeviceConsumption: Record<number, number> = {};
|
const totalDeviceConsumption: Record<number, number> = {};
|
||||||
|
|
||||||
processedData.forEach((device) => {
|
processedData.forEach((device) => {
|
||||||
device.data.forEach((datapoint) => {
|
device.data.forEach((datapoint) => {
|
||||||
totalDeviceConsumption[datapoint.x] =
|
totalDeviceConsumption[datapoint[0]] =
|
||||||
(totalDeviceConsumption[datapoint.x] || 0) + datapoint.y;
|
(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) => {
|
Object.keys(consumptionData.total).forEach((time) => {
|
||||||
untrackedConsumption.push({
|
const value =
|
||||||
x: Number(time),
|
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
|
||||||
y: 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 = {
|
const dataset: BarSeriesOption = {
|
||||||
label: this.hass.localize(
|
type: "bar",
|
||||||
|
id: compare ? "compare-untracked" : "untracked",
|
||||||
|
name: this.hass.localize(
|
||||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||||
),
|
),
|
||||||
hidden: this._hiddenStats.includes("untracked"),
|
itemStyle: {
|
||||||
borderColor: getEnergyColor(
|
borderColor: getEnergyColor(
|
||||||
computedStyle,
|
computedStyle,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
false,
|
false,
|
||||||
compare,
|
compare,
|
||||||
"--state-unavailable-color"
|
"--state-unavailable-color"
|
||||||
),
|
),
|
||||||
backgroundColor: getEnergyColor(
|
},
|
||||||
|
barMaxWidth: 50,
|
||||||
|
color: getEnergyColor(
|
||||||
computedStyle,
|
computedStyle,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
true,
|
true,
|
||||||
@ -392,15 +359,9 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
"--state-unavailable-color"
|
"--state-unavailable-color"
|
||||||
),
|
),
|
||||||
data: untrackedConsumption,
|
data: untrackedConsumption,
|
||||||
order: 1 + this._untrackedIndex!,
|
stack: compare ? "devicesCompare" : "devices",
|
||||||
stack: "devices",
|
|
||||||
pointStyle: compare ? false : "circle",
|
|
||||||
xAxisID: compare ? "xAxisCompare" : undefined,
|
|
||||||
};
|
};
|
||||||
const datasetExtra = {
|
return dataset;
|
||||||
show_legend: !compare,
|
|
||||||
};
|
|
||||||
return { dataset, datasetExtra };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processDataSet(
|
private _processDataSet(
|
||||||
@ -411,70 +372,73 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
sorted_devices: string[],
|
sorted_devices: string[],
|
||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
const dataExtras: ChartDatasetExtra[] = [];
|
const compareOffset = compare
|
||||||
|
? this._start.getTime() - this._compareStart!.getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
devices.forEach((source, idx) => {
|
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);
|
const color = getGraphColorByIndex(idx, computedStyle);
|
||||||
|
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
|
|
||||||
const consumptionData: ScatterDataPoint[] = [];
|
const consumptionData: BarSeriesOption["data"] = [];
|
||||||
|
|
||||||
// Process gas consumption data.
|
// Process gas consumption data.
|
||||||
if (source.stat_consumption in statistics) {
|
if (source.stat_consumption in statistics) {
|
||||||
const stats = statistics[source.stat_consumption];
|
const stats = statistics[source.stat_consumption];
|
||||||
let end;
|
|
||||||
|
|
||||||
for (const point of stats) {
|
for (const point of stats) {
|
||||||
if (point.change === null || point.change === undefined) {
|
if (
|
||||||
|
point.change === null ||
|
||||||
|
point.change === undefined ||
|
||||||
|
point.change === 0
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const date = new Date(point.start);
|
const dataPoint = [point.start, point.change];
|
||||||
consumptionData.push({
|
if (compare) {
|
||||||
x: date.getTime(),
|
dataPoint[2] = dataPoint[0];
|
||||||
y: point.change,
|
dataPoint[0] += compareOffset;
|
||||||
});
|
}
|
||||||
|
consumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
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({
|
data.push({
|
||||||
label:
|
type: "bar",
|
||||||
|
id: compare
|
||||||
|
? `compare-${source.stat_consumption}`
|
||||||
|
: source.stat_consumption,
|
||||||
|
name:
|
||||||
source.name ||
|
source.name ||
|
||||||
getStatisticLabel(
|
getStatisticLabel(
|
||||||
this.hass,
|
this.hass,
|
||||||
source.stat_consumption,
|
source.stat_consumption,
|
||||||
statisticsMetaData[source.stat_consumption]
|
statisticsMetaData[source.stat_consumption]
|
||||||
),
|
),
|
||||||
hidden:
|
itemStyle: {
|
||||||
this._hiddenStats.includes(source.stat_consumption) || itemExceedsMax,
|
borderColor: compare ? color + "7F" : color,
|
||||||
borderColor: compare ? color + "7F" : color,
|
},
|
||||||
backgroundColor: compare ? color + "32" : color + "7F",
|
barMaxWidth: 50,
|
||||||
|
color: compare ? color + "32" : color + "7F",
|
||||||
data: consumptionData,
|
data: consumptionData,
|
||||||
order: 1 + order,
|
stack: compare ? "devicesCompare" : "devices",
|
||||||
stack: "devices",
|
|
||||||
pointStyle: compare ? false : "circle",
|
|
||||||
xAxisID: compare ? "xAxisCompare" : undefined,
|
|
||||||
});
|
});
|
||||||
dataExtras.push({ show_legend: !compare && !itemExceedsMax });
|
|
||||||
});
|
});
|
||||||
return { data, dataExtras };
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
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 { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { css, html, LitElement, nothing } 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 { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { BarSeriesOption } from "echarts/charts";
|
||||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
|
||||||
import {
|
import {
|
||||||
formatNumber,
|
formatNumber,
|
||||||
numberFormatToLocale,
|
getNumberFormatOptions,
|
||||||
} from "../../../../common/number/format_number";
|
} from "../../../../common/number/format_number";
|
||||||
import "../../../../components/chart/ha-chart-base";
|
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 type { EnergyData } from "../../../../data/energy";
|
||||||
import { getEnergyDataCollection } from "../../../../data/energy";
|
import { getEnergyDataCollection } from "../../../../data/energy";
|
||||||
import {
|
import {
|
||||||
calculateStatisticSumGrowth,
|
calculateStatisticSumGrowth,
|
||||||
getStatisticLabel,
|
getStatisticLabel,
|
||||||
isExternalStatistic,
|
|
||||||
} from "../../../../data/recorder";
|
} from "../../../../data/recorder";
|
||||||
import type { FrontendLocaleData } from "../../../../data/translation";
|
|
||||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergyDevicesGraphCardConfig } from "../types";
|
import type { EnergyDevicesGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
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")
|
@customElement("hui-energy-devices-graph-card")
|
||||||
export class HuiEnergyDevicesGraphCard
|
export class HuiEnergyDevicesGraphCard
|
||||||
@ -45,12 +34,10 @@ export class HuiEnergyDevicesGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergyDevicesGraphCardConfig;
|
@state() private _config?: EnergyDevicesGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = { datasets: [] };
|
@state() private _chartData: BarSeriesOption[] = [];
|
||||||
|
|
||||||
@state() private _data?: EnergyData;
|
@state() private _data?: EnergyData;
|
||||||
|
|
||||||
@query("ha-chart-base") private _chart?: HaChartBase;
|
|
||||||
|
|
||||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||||
|
|
||||||
public hassSubscribe(): UnsubscribeFunc[] {
|
public hassSubscribe(): UnsubscribeFunc[] {
|
||||||
@ -98,76 +85,53 @@ export class HuiEnergyDevicesGraphCard
|
|||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData}
|
||||||
.options=${this._createOptions(this.hass.locale)}
|
.options=${this._createOptions(this.hass.themes?.darkMode)}
|
||||||
.height=${(this._chartData?.datasets[0]?.data.length || 0) * 28 +
|
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
|
||||||
50}
|
|
||||||
chart-type="bar"
|
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createOptions = memoizeOne(
|
private _renderTooltip(params: any) {
|
||||||
(locale: FrontendLocaleData): ChartOptions => ({
|
const title = `<h4 style="text-align: center; margin: 0;">${this._getDeviceName(
|
||||||
parsing: false,
|
params.value[1]
|
||||||
responsive: true,
|
)}</h4>`;
|
||||||
maintainAspectRatio: false,
|
const value = `${formatNumber(
|
||||||
indexAxis: "y",
|
params.value[0] as number,
|
||||||
scales: {
|
this.hass.locale,
|
||||||
y: {
|
getNumberFormatOptions(undefined, this.hass.entities[params.value[1]])
|
||||||
type: "category",
|
)} kWh`;
|
||||||
ticks: {
|
return `${title}${params.marker} ${params.seriesName}: ${value}`;
|
||||||
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);
|
|
||||||
|
|
||||||
const index = Math.abs(
|
private _createOptions = memoizeOne(
|
||||||
chart.scales.y.getValueForPixel(canvasPosition.y)
|
(darkMode: boolean): ECOption => ({
|
||||||
);
|
xAxis: {
|
||||||
// @ts-ignore
|
type: "value",
|
||||||
const statisticId = this._chartData?.datasets[0]?.data[index]?.y;
|
name: "kWh",
|
||||||
if (!statisticId || isExternalStatistic(statisticId)) return;
|
splitLine: {
|
||||||
fireEvent(this, "hass-more-info", {
|
lineStyle: darkMode ? { opacity: 0.15 } : {},
|
||||||
entityId: statisticId,
|
},
|
||||||
});
|
},
|
||||||
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
|
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 data = energyData.stats;
|
||||||
const compareData = energyData.statsCompare;
|
const compareData = energyData.statsCompare;
|
||||||
|
|
||||||
const chartData: ChartDataset<"bar", ParsedDataType<"bar">>["data"][] = [];
|
const chartData: NonNullable<BarSeriesOption["data"]> = [];
|
||||||
const chartDataCompare: ChartDataset<
|
const chartDataCompare: NonNullable<BarSeriesOption["data"]> = [];
|
||||||
"bar",
|
|
||||||
ParsedDataType<"bar">
|
|
||||||
>["data"][] = [];
|
|
||||||
const borderColor: string[] = [];
|
|
||||||
const borderColorCompare: string[] = [];
|
|
||||||
const backgroundColor: string[] = [];
|
|
||||||
const backgroundColorCompare: string[] = [];
|
|
||||||
|
|
||||||
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"
|
"ui.panel.lovelace.cards.energy.energy_devices_graph.energy_usage"
|
||||||
),
|
),
|
||||||
borderColor,
|
itemStyle: {
|
||||||
backgroundColor,
|
borderRadius: [0, 4, 4, 0],
|
||||||
|
},
|
||||||
data: chartData,
|
data: chartData,
|
||||||
barThickness: compareData ? 10 : 20,
|
barWidth: compareData ? 10 : 20,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (compareData) {
|
if (compareData) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
label: this.hass.localize(
|
type: "bar",
|
||||||
|
name: this.hass.localize(
|
||||||
"ui.panel.lovelace.cards.energy.energy_devices_graph.previous_energy_usage"
|
"ui.panel.lovelace.cards.energy.energy_devices_graph.previous_energy_usage"
|
||||||
),
|
),
|
||||||
borderColor: borderColorCompare,
|
itemStyle: {
|
||||||
backgroundColor: backgroundColorCompare,
|
borderRadius: [0, 4, 4, 0],
|
||||||
|
},
|
||||||
data: chartDataCompare,
|
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 =
|
const value =
|
||||||
device.stat_consumption in data
|
device.stat_consumption in data
|
||||||
? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0
|
? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0
|
||||||
: 0;
|
: 0;
|
||||||
|
const color = getGraphColorByIndex(id, computedStyle);
|
||||||
|
|
||||||
chartData.push({
|
chartData.push({
|
||||||
// @ts-expect-error
|
id,
|
||||||
y: device.stat_consumption,
|
value: [value, device.stat_consumption],
|
||||||
x: value,
|
itemStyle: {
|
||||||
idx,
|
color: color + "7F",
|
||||||
|
borderColor: color,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (compareData) {
|
if (compareData) {
|
||||||
@ -245,40 +211,22 @@ export class HuiEnergyDevicesGraphCard
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
chartDataCompare.push({
|
chartDataCompare.push({
|
||||||
// @ts-expect-error
|
id,
|
||||||
y: device.stat_consumption,
|
value: [compareValue, device.stat_consumption],
|
||||||
x: compareValue,
|
itemStyle: {
|
||||||
idx,
|
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;
|
chartData.length = this._config?.max_devices || chartData.length;
|
||||||
|
|
||||||
const computedStyle = getComputedStyle(this);
|
this._chartData = datasets;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
this._chart?.updateChart("none");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
import type {
|
|
||||||
ChartData,
|
|
||||||
ChartDataset,
|
|
||||||
ChartOptions,
|
|
||||||
ScatterDataPoint,
|
|
||||||
} from "chart.js";
|
|
||||||
import { endOfToday, isToday, startOfToday } from "date-fns";
|
import { endOfToday, isToday, startOfToday } from "date-fns";
|
||||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { BarSeriesOption } from "echarts/charts";
|
||||||
import { getEnergyColor } from "./common/color";
|
import { getEnergyColor } from "./common/color";
|
||||||
import { formatNumber } from "../../../../common/number/format_number";
|
import { formatNumber } from "../../../../common/number/format_number";
|
||||||
import "../../../../components/chart/ha-chart-base";
|
import "../../../../components/chart/ha-chart-base";
|
||||||
@ -31,7 +26,11 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergyGasGraphCardConfig } from "../types";
|
import type { EnergyGasGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
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")
|
@customElement("hui-energy-gas-graph-card")
|
||||||
export class HuiEnergyGasGraphCard
|
export class HuiEnergyGasGraphCard
|
||||||
@ -42,9 +41,7 @@ export class HuiEnergyGasGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergyGasGraphCardConfig;
|
@state() private _config?: EnergyGasGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = {
|
@state() private _chartData: BarSeriesOption[] = [];
|
||||||
datasets: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
@state() private _start = startOfToday();
|
@state() private _start = startOfToday();
|
||||||
|
|
||||||
@ -111,7 +108,7 @@ export class HuiEnergyGasGraphCard
|
|||||||
)}
|
)}
|
||||||
chart-type="bar"
|
chart-type="bar"
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
${!this._chartData.datasets.length
|
${!this._chartData.length
|
||||||
? html`<div class="no-data">
|
? html`<div class="no-data">
|
||||||
${isToday(this._start)
|
${isToday(this._start)
|
||||||
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
? 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(
|
private _createOptions = memoizeOne(
|
||||||
(
|
(
|
||||||
start: Date,
|
start: Date,
|
||||||
@ -134,51 +137,26 @@ export class HuiEnergyGasGraphCard
|
|||||||
unit?: string,
|
unit?: string,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date
|
||||||
): ChartOptions => {
|
): ECOption =>
|
||||||
const commonOptions = getCommonOptions(
|
getCommonOptions(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
locale,
|
locale,
|
||||||
config,
|
config,
|
||||||
unit,
|
unit,
|
||||||
compareStart,
|
compareStart,
|
||||||
compareEnd
|
compareEnd,
|
||||||
);
|
this._formatTotal
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
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[] =
|
const gasSources: GasSourceTypeEnergyPreference[] =
|
||||||
energyData.prefs.energy_sources.filter(
|
energyData.prefs.energy_sources.filter(
|
||||||
(source) => source.type === "gas"
|
(source) => source.type === "gas"
|
||||||
@ -188,10 +166,32 @@ export class HuiEnergyGasGraphCard
|
|||||||
getEnergyGasUnit(this.hass, energyData.prefs, energyData.statsMetadata) ||
|
getEnergyGasUnit(this.hass, energyData.prefs, energyData.statsMetadata) ||
|
||||||
"m³";
|
"m³";
|
||||||
|
|
||||||
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const datasets: BarSeriesOption[] = [];
|
||||||
|
|
||||||
const computedStyles = getComputedStyle(this);
|
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(
|
datasets.push(
|
||||||
...this._processDataSet(
|
...this._processDataSet(
|
||||||
energyData.stats,
|
energyData.stats,
|
||||||
@ -201,38 +201,8 @@ export class HuiEnergyGasGraphCard
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (energyData.statsCompare) {
|
fillDataGapsAndRoundCaps(datasets);
|
||||||
// Add empty dataset to align the bars
|
this._chartData = datasets;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processDataSet(
|
private _processDataSet(
|
||||||
@ -242,56 +212,62 @@ export class HuiEnergyGasGraphCard
|
|||||||
computedStyles: CSSStyleDeclaration,
|
computedStyles: CSSStyleDeclaration,
|
||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
|
const compareOffset = compare
|
||||||
|
? this._start.getTime() - this._compareStart!.getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
gasSources.forEach((source, idx) => {
|
gasSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
|
|
||||||
const gasConsumptionData: ScatterDataPoint[] = [];
|
const gasConsumptionData: BarSeriesOption["data"] = [];
|
||||||
|
|
||||||
// Process gas consumption data.
|
// Process gas consumption data.
|
||||||
if (source.stat_energy_from in statistics) {
|
if (source.stat_energy_from in statistics) {
|
||||||
const stats = statistics[source.stat_energy_from];
|
const stats = statistics[source.stat_energy_from];
|
||||||
let end;
|
|
||||||
|
|
||||||
for (const point of stats) {
|
for (const point of stats) {
|
||||||
if (point.change === null || point.change === undefined) {
|
if (
|
||||||
|
point.change === null ||
|
||||||
|
point.change === undefined ||
|
||||||
|
point.change === 0
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const date = new Date(point.start);
|
const dataPoint = [point.start, point.change];
|
||||||
gasConsumptionData.push({
|
if (compare) {
|
||||||
x: date.getTime(),
|
dataPoint[2] = dataPoint[0];
|
||||||
y: point.change,
|
dataPoint[0] += compareOffset;
|
||||||
});
|
}
|
||||||
|
gasConsumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
end = point.end;
|
|
||||||
}
|
|
||||||
if (gasConsumptionData.length === 1) {
|
|
||||||
gasConsumptionData.push({
|
|
||||||
x: end,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.push({
|
data.push({
|
||||||
label: getStatisticLabel(
|
type: "bar",
|
||||||
|
id: compare
|
||||||
|
? "compare-" + source.stat_energy_from
|
||||||
|
: source.stat_energy_from,
|
||||||
|
name: getStatisticLabel(
|
||||||
this.hass,
|
this.hass,
|
||||||
source.stat_energy_from,
|
source.stat_energy_from,
|
||||||
statisticsMetaData[source.stat_energy_from]
|
statisticsMetaData[source.stat_energy_from]
|
||||||
),
|
),
|
||||||
borderColor: getEnergyColor(
|
barMaxWidth: 50,
|
||||||
computedStyles,
|
itemStyle: {
|
||||||
this.hass.themes.darkMode,
|
borderColor: getEnergyColor(
|
||||||
false,
|
computedStyles,
|
||||||
compare,
|
this.hass.themes.darkMode,
|
||||||
"--energy-gas-color",
|
false,
|
||||||
idx
|
compare,
|
||||||
),
|
"--energy-gas-color",
|
||||||
backgroundColor: getEnergyColor(
|
idx
|
||||||
|
),
|
||||||
|
},
|
||||||
|
color: getEnergyColor(
|
||||||
computedStyles,
|
computedStyles,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
true,
|
true,
|
||||||
@ -300,9 +276,7 @@ export class HuiEnergyGasGraphCard
|
|||||||
idx
|
idx
|
||||||
),
|
),
|
||||||
data: gasConsumptionData,
|
data: gasConsumptionData,
|
||||||
order: 1,
|
stack: compare ? "compare-gas" : "gas",
|
||||||
stack: "gas",
|
|
||||||
xAxisID: compare ? "xAxisCompare" : undefined,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
import type {
|
|
||||||
ChartData,
|
|
||||||
ChartDataset,
|
|
||||||
ChartOptions,
|
|
||||||
ScatterDataPoint,
|
|
||||||
} from "chart.js";
|
|
||||||
import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns";
|
import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns";
|
||||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
|
||||||
import { getEnergyColor } from "./common/color";
|
import { getEnergyColor } from "./common/color";
|
||||||
import { formatNumber } from "../../../../common/number/format_number";
|
import { formatNumber } from "../../../../common/number/format_number";
|
||||||
import "../../../../components/chart/ha-chart-base";
|
import "../../../../components/chart/ha-chart-base";
|
||||||
@ -32,7 +27,11 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergySolarGraphCardConfig } from "../types";
|
import type { EnergySolarGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
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")
|
@customElement("hui-energy-solar-graph-card")
|
||||||
export class HuiEnergySolarGraphCard
|
export class HuiEnergySolarGraphCard
|
||||||
@ -43,9 +42,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergySolarGraphCardConfig;
|
@state() private _config?: EnergySolarGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = {
|
@state() private _chartData: ECOption["series"][] = [];
|
||||||
datasets: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
@state() private _start = startOfToday();
|
@state() private _start = startOfToday();
|
||||||
|
|
||||||
@ -109,7 +106,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
)}
|
)}
|
||||||
chart-type="bar"
|
chart-type="bar"
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
${!this._chartData.datasets.length
|
${!this._chartData.length
|
||||||
? html`<div class="no-data">
|
? html`<div class="no-data">
|
||||||
${isToday(this._start)
|
${isToday(this._start)
|
||||||
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
? 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(
|
private _createOptions = memoizeOne(
|
||||||
(
|
(
|
||||||
start: Date,
|
start: Date,
|
||||||
@ -131,64 +134,26 @@ export class HuiEnergySolarGraphCard
|
|||||||
config: HassConfig,
|
config: HassConfig,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date
|
||||||
): ChartOptions => {
|
): ECOption =>
|
||||||
const commonOptions = getCommonOptions(
|
getCommonOptions(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
locale,
|
locale,
|
||||||
config,
|
config,
|
||||||
"kWh",
|
"kWh",
|
||||||
compareStart,
|
compareStart,
|
||||||
compareEnd
|
compareEnd,
|
||||||
);
|
this._formatTotal
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
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[] =
|
const solarSources: SolarSourceTypeEnergyPreference[] =
|
||||||
energyData.prefs.energy_sources.filter(
|
energyData.prefs.energy_sources.filter(
|
||||||
(source) => source.type === "solar"
|
(source) => source.type === "solar"
|
||||||
@ -205,10 +170,32 @@ export class HuiEnergySolarGraphCard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const datasets: ChartDataset<"bar" | "line">[] = [];
|
const datasets: ECOption["series"] = [];
|
||||||
|
|
||||||
const computedStyles = getComputedStyle(this);
|
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(
|
datasets.push(
|
||||||
...this._processDataSet(
|
...this._processDataSet(
|
||||||
energyData.stats,
|
energyData.stats,
|
||||||
@ -218,28 +205,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (energyData.statsCompare) {
|
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
|
||||||
// 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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forecasts) {
|
if (forecasts) {
|
||||||
datasets.push(
|
datasets.push(
|
||||||
@ -254,15 +220,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._start = energyData.start;
|
this._chartData = datasets;
|
||||||
this._end = energyData.end || endOfToday();
|
|
||||||
|
|
||||||
this._compareStart = energyData.startCompare;
|
|
||||||
this._compareEnd = energyData.endCompare;
|
|
||||||
|
|
||||||
this._chartData = {
|
|
||||||
datasets,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processDataSet(
|
private _processDataSet(
|
||||||
@ -272,43 +230,47 @@ export class HuiEnergySolarGraphCard
|
|||||||
computedStyles: CSSStyleDeclaration,
|
computedStyles: CSSStyleDeclaration,
|
||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
|
const compareOffset = compare
|
||||||
|
? this._start.getTime() - this._compareStart!.getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
solarSources.forEach((source, idx) => {
|
solarSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
|
|
||||||
const solarProductionData: ScatterDataPoint[] = [];
|
const solarProductionData: BarSeriesOption["data"] = [];
|
||||||
|
|
||||||
// Process solar production data.
|
// Process solar production data.
|
||||||
if (source.stat_energy_from in statistics) {
|
if (source.stat_energy_from in statistics) {
|
||||||
const stats = statistics[source.stat_energy_from];
|
const stats = statistics[source.stat_energy_from];
|
||||||
let end;
|
|
||||||
|
|
||||||
for (const point of stats) {
|
for (const point of stats) {
|
||||||
if (point.change === null || point.change === undefined) {
|
if (
|
||||||
|
point.change === null ||
|
||||||
|
point.change === undefined ||
|
||||||
|
point.change === 0
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const date = new Date(point.start);
|
const dataPoint = [point.start, point.change];
|
||||||
solarProductionData.push({
|
if (compare) {
|
||||||
x: date.getTime(),
|
dataPoint[2] = dataPoint[0];
|
||||||
y: point.change,
|
dataPoint[0] += compareOffset;
|
||||||
});
|
}
|
||||||
|
solarProductionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
end = point.end;
|
|
||||||
}
|
|
||||||
if (solarProductionData.length === 1) {
|
|
||||||
solarProductionData.push({
|
|
||||||
x: end,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.push({
|
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",
|
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
|
||||||
{
|
{
|
||||||
name: getStatisticLabel(
|
name: getStatisticLabel(
|
||||||
@ -318,15 +280,18 @@ export class HuiEnergySolarGraphCard
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
borderColor: getEnergyColor(
|
barMaxWidth: 50,
|
||||||
computedStyles,
|
itemStyle: {
|
||||||
this.hass.themes.darkMode,
|
borderColor: getEnergyColor(
|
||||||
false,
|
computedStyles,
|
||||||
compare,
|
this.hass.themes.darkMode,
|
||||||
"--energy-solar-color",
|
false,
|
||||||
idx
|
compare,
|
||||||
),
|
"--energy-solar-color",
|
||||||
backgroundColor: getEnergyColor(
|
idx
|
||||||
|
),
|
||||||
|
},
|
||||||
|
color: getEnergyColor(
|
||||||
computedStyles,
|
computedStyles,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
true,
|
true,
|
||||||
@ -335,9 +300,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
idx
|
idx
|
||||||
),
|
),
|
||||||
data: solarProductionData,
|
data: solarProductionData,
|
||||||
order: 1,
|
stack: compare ? "compare" : "solar",
|
||||||
stack: "solar",
|
|
||||||
xAxisID: compare ? "xAxisCompare" : undefined,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -352,7 +315,7 @@ export class HuiEnergySolarGraphCard
|
|||||||
start: Date,
|
start: Date,
|
||||||
end?: Date
|
end?: Date
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"line">[] = [];
|
const data: LineSeriesOption[] = [];
|
||||||
|
|
||||||
const dayDifference = differenceInDays(end || new Date(), start);
|
const dayDifference = differenceInDays(end || new Date(), start);
|
||||||
|
|
||||||
@ -389,18 +352,16 @@ export class HuiEnergySolarGraphCard
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (forecastsData) {
|
if (forecastsData) {
|
||||||
const solarForecastData: ScatterDataPoint[] = [];
|
const solarForecastData: LineSeriesOption["data"] = [];
|
||||||
for (const [time, value] of Object.entries(forecastsData)) {
|
for (const [time, value] of Object.entries(forecastsData)) {
|
||||||
solarForecastData.push({
|
solarForecastData.push([Number(time), value / 1000]);
|
||||||
x: Number(time),
|
|
||||||
y: value / 1000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (solarForecastData.length) {
|
if (solarForecastData.length) {
|
||||||
data.push({
|
data.push({
|
||||||
|
id: "forecast-" + source.stat_energy_from,
|
||||||
type: "line",
|
type: "line",
|
||||||
label: this.hass.localize(
|
name: this.hass.localize(
|
||||||
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
|
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
|
||||||
{
|
{
|
||||||
name: getStatisticLabel(
|
name: getStatisticLabel(
|
||||||
@ -410,11 +371,13 @@ export class HuiEnergySolarGraphCard
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
fill: false,
|
step: false,
|
||||||
stepped: false,
|
color: borderColor,
|
||||||
borderColor,
|
lineStyle: {
|
||||||
borderDash: [7, 5],
|
type: [7, 5],
|
||||||
pointRadius: 0,
|
width: 1.5,
|
||||||
|
},
|
||||||
|
symbol: "none",
|
||||||
data: solarForecastData,
|
data: solarForecastData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
import type {
|
|
||||||
ChartData,
|
|
||||||
ChartDataset,
|
|
||||||
ChartOptions,
|
|
||||||
ScatterDataPoint,
|
|
||||||
} from "chart.js";
|
|
||||||
import { endOfToday, isToday, startOfToday } from "date-fns";
|
import { endOfToday, isToday, startOfToday } from "date-fns";
|
||||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
@ -11,6 +5,11 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
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 { getEnergyColor } from "./common/color";
|
||||||
import { formatNumber } from "../../../../common/number/format_number";
|
import { formatNumber } from "../../../../common/number/format_number";
|
||||||
import "../../../../components/chart/ha-chart-base";
|
import "../../../../components/chart/ha-chart-base";
|
||||||
@ -25,7 +24,11 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergyUsageGraphCardConfig } from "../types";
|
import type { EnergyUsageGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
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 = {
|
const colorPropertyMap = {
|
||||||
to_grid: "--energy-grid-return-color",
|
to_grid: "--energy-grid-return-color",
|
||||||
@ -45,9 +48,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergyUsageGraphCardConfig;
|
@state() private _config?: EnergyUsageGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = {
|
@state() private _chartData: BarSeriesOption[] = [];
|
||||||
datasets: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
@state() private _start = startOfToday();
|
@state() private _start = startOfToday();
|
||||||
|
|
||||||
@ -111,7 +112,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
)}
|
)}
|
||||||
chart-type="bar"
|
chart-type="bar"
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
${!this._chartData.datasets.some((dataset) => dataset.data.length)
|
${!this._chartData.some((dataset) => dataset.data!.length)
|
||||||
? html`<div class="no-data">
|
? html`<div class="no-data">
|
||||||
${isToday(this._start)
|
${isToday(this._start)
|
||||||
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
? 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(
|
private _createOptions = memoizeOne(
|
||||||
(
|
(
|
||||||
start: Date,
|
start: Date,
|
||||||
@ -133,7 +145,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
config: HassConfig,
|
config: HassConfig,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date
|
||||||
): ChartOptions => {
|
): ECOption => {
|
||||||
const commonOptions = getCommonOptions(
|
const commonOptions = getCommonOptions(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@ -141,56 +153,34 @@ export class HuiEnergyUsageGraphCard
|
|||||||
config,
|
config,
|
||||||
"kWh",
|
"kWh",
|
||||||
compareStart,
|
compareStart,
|
||||||
compareEnd
|
compareEnd,
|
||||||
|
this._formatTotal
|
||||||
);
|
);
|
||||||
const options: ChartOptions = {
|
const options: ECOption = {
|
||||||
...commonOptions,
|
...commonOptions,
|
||||||
plugins: {
|
tooltip: {
|
||||||
...commonOptions.plugins,
|
...commonOptions.tooltip,
|
||||||
tooltip: {
|
formatter: (params: TopLevelFormatterParams): string => {
|
||||||
...commonOptions.plugins!.tooltip,
|
if (!Array.isArray(params)) {
|
||||||
itemSort: function (a: any, b: any) {
|
return "";
|
||||||
if (a.raw?.y > 0 && b.raw?.y < 0) {
|
}
|
||||||
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
if (b.raw?.y > 0 && a.raw?.y < 0) {
|
if (bValue > 0 && aValue < 0) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (a.raw?.y > 0) {
|
if (aValue > 0) {
|
||||||
return b.datasetIndex - a.datasetIndex;
|
return b.componentIndex - a.componentIndex;
|
||||||
}
|
}
|
||||||
return a.datasetIndex - b.datasetIndex;
|
return a.componentIndex - b.componentIndex;
|
||||||
},
|
});
|
||||||
callbacks: {
|
return (
|
||||||
...commonOptions.plugins!.tooltip!.callbacks,
|
(commonOptions.tooltip as TooltipOption)?.formatter as any
|
||||||
footer: (contexts) => {
|
)?.(params);
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -199,7 +189,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
);
|
);
|
||||||
|
|
||||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||||
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const datasets: BarSeriesOption[] = [];
|
||||||
|
|
||||||
const statIds: {
|
const statIds: {
|
||||||
to_grid?: string[];
|
to_grid?: string[];
|
||||||
@ -288,6 +278,30 @@ export class HuiEnergyUsageGraphCard
|
|||||||
this._compareStart = energyData.startCompare;
|
this._compareStart = energyData.startCompare;
|
||||||
this._compareEnd = energyData.endCompare;
|
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(
|
datasets.push(
|
||||||
...this._processDataSet(
|
...this._processDataSet(
|
||||||
energyData.stats,
|
energyData.stats,
|
||||||
@ -300,34 +314,8 @@ export class HuiEnergyUsageGraphCard
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (energyData.statsCompare) {
|
fillDataGapsAndRoundCaps(datasets);
|
||||||
// Add empty dataset to align the bars
|
this._chartData = datasets;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processDataSet(
|
private _processDataSet(
|
||||||
@ -349,7 +337,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
},
|
},
|
||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
|
|
||||||
const combinedData: {
|
const combinedData: {
|
||||||
to_grid?: Record<string, Record<number, number>>;
|
to_grid?: Record<string, Record<number, number>>;
|
||||||
@ -368,7 +356,6 @@ export class HuiEnergyUsageGraphCard
|
|||||||
solar?: Record<number, number>;
|
solar?: Record<number, number>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
let pointEndTime;
|
|
||||||
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
|
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
|
||||||
const sum = [
|
const sum = [
|
||||||
"solar",
|
"solar",
|
||||||
@ -396,11 +383,9 @@ export class HuiEnergyUsageGraphCard
|
|||||||
if (sum) {
|
if (sum) {
|
||||||
totalStats[stat.start] =
|
totalStats[stat.start] =
|
||||||
stat.start in totalStats ? totalStats[stat.start] + val : val;
|
stat.start in totalStats ? totalStats[stat.start] + val : val;
|
||||||
pointEndTime = stat.end;
|
|
||||||
}
|
}
|
||||||
if (add && !(stat.start in set)) {
|
if (add && !(stat.start in set)) {
|
||||||
set[stat.start] = val;
|
set[stat.start] = val;
|
||||||
pointEndTime = stat.end;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
sets[id] = set;
|
sets[id] = set;
|
||||||
@ -491,29 +476,33 @@ export class HuiEnergyUsageGraphCard
|
|||||||
(a, b) => Number(a) - Number(b)
|
(a, b) => Number(a) - Number(b)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const compareOffset = compare
|
||||||
|
? this._start.getTime() - this._compareStart!.getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
Object.entries(combinedData).forEach(([type, sources]) => {
|
Object.entries(combinedData).forEach(([type, sources]) => {
|
||||||
Object.entries(sources).forEach(([statId, source], idx) => {
|
Object.entries(sources).forEach(([statId, source]) => {
|
||||||
const points: ScatterDataPoint[] = [];
|
const points: BarSeriesOption["data"] = [];
|
||||||
// Process chart data.
|
// Process chart data.
|
||||||
for (const key of uniqueKeys) {
|
for (const key of uniqueKeys) {
|
||||||
const value = source[key] || 0;
|
const value = source[key] || 0;
|
||||||
points.push({
|
const dataPoint = [
|
||||||
x: Number(key),
|
Number(key),
|
||||||
y:
|
value && ["to_grid", "to_battery"].includes(type)
|
||||||
value && ["to_grid", "to_battery"].includes(type)
|
? -1 * value
|
||||||
? -1 * value
|
: value,
|
||||||
: value,
|
];
|
||||||
});
|
if (compare) {
|
||||||
}
|
dataPoint[2] = dataPoint[0];
|
||||||
if (points.length === 1) {
|
dataPoint[0] += compareOffset;
|
||||||
points.push({
|
}
|
||||||
x: pointEndTime,
|
points.push(dataPoint);
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data.push({
|
data.push({
|
||||||
label:
|
id: compare ? "compare-" + statId : statId,
|
||||||
|
type: "bar",
|
||||||
|
name:
|
||||||
type in labels
|
type in labels
|
||||||
? labels[type]
|
? labels[type]
|
||||||
: getStatisticLabel(
|
: getStatisticLabel(
|
||||||
@ -521,21 +510,18 @@ export class HuiEnergyUsageGraphCard
|
|||||||
statId,
|
statId,
|
||||||
statisticsMetaData[statId]
|
statisticsMetaData[statId]
|
||||||
),
|
),
|
||||||
order:
|
barMaxWidth: 50,
|
||||||
type === "used_solar"
|
itemStyle: {
|
||||||
? 1
|
borderColor: getEnergyColor(
|
||||||
: type === "to_battery"
|
computedStyles,
|
||||||
? Object.keys(combinedData).length
|
this.hass.themes.darkMode,
|
||||||
: idx + 2,
|
false,
|
||||||
borderColor: getEnergyColor(
|
compare,
|
||||||
computedStyles,
|
colorPropertyMap[type],
|
||||||
this.hass.themes.darkMode,
|
colorIndices[type]?.[statId]
|
||||||
false,
|
),
|
||||||
compare,
|
},
|
||||||
colorPropertyMap[type],
|
color: getEnergyColor(
|
||||||
colorIndices[type]?.[statId]
|
|
||||||
),
|
|
||||||
backgroundColor: getEnergyColor(
|
|
||||||
computedStyles,
|
computedStyles,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
true,
|
true,
|
||||||
@ -543,9 +529,8 @@ export class HuiEnergyUsageGraphCard
|
|||||||
colorPropertyMap[type],
|
colorPropertyMap[type],
|
||||||
colorIndices[type]?.[statId]
|
colorIndices[type]?.[statId]
|
||||||
),
|
),
|
||||||
stack: "stack",
|
stack: compare ? "compare" : "usage",
|
||||||
data: points,
|
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 { endOfToday, isToday, startOfToday } from "date-fns";
|
||||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
@ -11,8 +5,8 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { BarSeriesOption } from "echarts/charts";
|
||||||
import { getEnergyColor } from "./common/color";
|
import { getEnergyColor } from "./common/color";
|
||||||
import { formatNumber } from "../../../../common/number/format_number";
|
|
||||||
import "../../../../components/chart/ha-chart-base";
|
import "../../../../components/chart/ha-chart-base";
|
||||||
import "../../../../components/ha-card";
|
import "../../../../components/ha-card";
|
||||||
import type {
|
import type {
|
||||||
@ -31,7 +25,12 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { LovelaceCard } from "../../types";
|
import type { LovelaceCard } from "../../types";
|
||||||
import type { EnergyWaterGraphCardConfig } from "../types";
|
import type { EnergyWaterGraphCardConfig } from "../types";
|
||||||
import { hasConfigChanged } from "../../common/has-changed";
|
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")
|
@customElement("hui-energy-water-graph-card")
|
||||||
export class HuiEnergyWaterGraphCard
|
export class HuiEnergyWaterGraphCard
|
||||||
@ -42,9 +41,7 @@ export class HuiEnergyWaterGraphCard
|
|||||||
|
|
||||||
@state() private _config?: EnergyWaterGraphCardConfig;
|
@state() private _config?: EnergyWaterGraphCardConfig;
|
||||||
|
|
||||||
@state() private _chartData: ChartData = {
|
@state() private _chartData: BarSeriesOption[] = [];
|
||||||
datasets: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
@state() private _start = startOfToday();
|
@state() private _start = startOfToday();
|
||||||
|
|
||||||
@ -111,7 +108,7 @@ export class HuiEnergyWaterGraphCard
|
|||||||
)}
|
)}
|
||||||
chart-type="bar"
|
chart-type="bar"
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
${!this._chartData.datasets.length
|
${!this._chartData.length
|
||||||
? html`<div class="no-data">
|
? html`<div class="no-data">
|
||||||
${isToday(this._start)
|
${isToday(this._start)
|
||||||
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
? 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(
|
private _createOptions = memoizeOne(
|
||||||
(
|
(
|
||||||
start: Date,
|
start: Date,
|
||||||
@ -134,51 +137,26 @@ export class HuiEnergyWaterGraphCard
|
|||||||
unit?: string,
|
unit?: string,
|
||||||
compareStart?: Date,
|
compareStart?: Date,
|
||||||
compareEnd?: Date
|
compareEnd?: Date
|
||||||
): ChartOptions => {
|
): ECOption =>
|
||||||
const commonOptions = getCommonOptions(
|
getCommonOptions(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
locale,
|
locale,
|
||||||
config,
|
config,
|
||||||
unit,
|
unit,
|
||||||
compareStart,
|
compareStart,
|
||||||
compareEnd
|
compareEnd,
|
||||||
);
|
this._formatTotal
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
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[] =
|
const waterSources: WaterSourceTypeEnergyPreference[] =
|
||||||
energyData.prefs.energy_sources.filter(
|
energyData.prefs.energy_sources.filter(
|
||||||
(source) => source.type === "water"
|
(source) => source.type === "water"
|
||||||
@ -186,10 +164,32 @@ export class HuiEnergyWaterGraphCard
|
|||||||
|
|
||||||
this._unit = getEnergyWaterUnit(this.hass);
|
this._unit = getEnergyWaterUnit(this.hass);
|
||||||
|
|
||||||
const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const datasets: BarSeriesOption[] = [];
|
||||||
|
|
||||||
const computedStyles = getComputedStyle(this);
|
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(
|
datasets.push(
|
||||||
...this._processDataSet(
|
...this._processDataSet(
|
||||||
energyData.stats,
|
energyData.stats,
|
||||||
@ -199,38 +199,8 @@ export class HuiEnergyWaterGraphCard
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (energyData.statsCompare) {
|
fillDataGapsAndRoundCaps(datasets);
|
||||||
// Add empty dataset to align the bars
|
this._chartData = datasets;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processDataSet(
|
private _processDataSet(
|
||||||
@ -240,56 +210,62 @@ export class HuiEnergyWaterGraphCard
|
|||||||
computedStyles: CSSStyleDeclaration,
|
computedStyles: CSSStyleDeclaration,
|
||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: ChartDataset<"bar", ScatterDataPoint[]>[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
|
const compareOffset = compare
|
||||||
|
? this._start.getTime() - this._compareStart!.getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
waterSources.forEach((source, idx) => {
|
waterSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
|
|
||||||
const waterConsumptionData: ScatterDataPoint[] = [];
|
const waterConsumptionData: BarSeriesOption["data"] = [];
|
||||||
|
|
||||||
// Process water consumption data.
|
// Process water consumption data.
|
||||||
if (source.stat_energy_from in statistics) {
|
if (source.stat_energy_from in statistics) {
|
||||||
const stats = statistics[source.stat_energy_from];
|
const stats = statistics[source.stat_energy_from];
|
||||||
let end;
|
|
||||||
|
|
||||||
for (const point of stats) {
|
for (const point of stats) {
|
||||||
if (point.change === null || point.change === undefined) {
|
if (
|
||||||
|
point.change === null ||
|
||||||
|
point.change === undefined ||
|
||||||
|
point.change === 0
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const date = new Date(point.start);
|
const dataPoint = [point.start, point.change];
|
||||||
waterConsumptionData.push({
|
if (compare) {
|
||||||
x: date.getTime(),
|
dataPoint[2] = dataPoint[0];
|
||||||
y: point.change,
|
dataPoint[0] += compareOffset;
|
||||||
});
|
}
|
||||||
|
waterConsumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
end = point.end;
|
|
||||||
}
|
|
||||||
if (waterConsumptionData.length === 1) {
|
|
||||||
waterConsumptionData.push({
|
|
||||||
x: end,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.push({
|
data.push({
|
||||||
label: getStatisticLabel(
|
type: "bar",
|
||||||
|
id: compare
|
||||||
|
? "compare-" + source.stat_energy_from
|
||||||
|
: source.stat_energy_from,
|
||||||
|
name: getStatisticLabel(
|
||||||
this.hass,
|
this.hass,
|
||||||
source.stat_energy_from,
|
source.stat_energy_from,
|
||||||
statisticsMetaData[source.stat_energy_from]
|
statisticsMetaData[source.stat_energy_from]
|
||||||
),
|
),
|
||||||
borderColor: getEnergyColor(
|
barMaxWidth: 50,
|
||||||
computedStyles,
|
itemStyle: {
|
||||||
this.hass.themes.darkMode,
|
borderColor: getEnergyColor(
|
||||||
false,
|
computedStyles,
|
||||||
compare,
|
this.hass.themes.darkMode,
|
||||||
"--energy-water-color",
|
false,
|
||||||
idx
|
compare,
|
||||||
),
|
"--energy-water-color",
|
||||||
backgroundColor: getEnergyColor(
|
idx
|
||||||
|
),
|
||||||
|
},
|
||||||
|
color: getEnergyColor(
|
||||||
computedStyles,
|
computedStyles,
|
||||||
this.hass.themes.darkMode,
|
this.hass.themes.darkMode,
|
||||||
true,
|
true,
|
||||||
@ -298,9 +274,7 @@ export class HuiEnergyWaterGraphCard
|
|||||||
idx
|
idx
|
||||||
),
|
),
|
||||||
data: waterConsumptionData,
|
data: waterConsumptionData,
|
||||||
order: 1,
|
stack: compare ? "compare" : "water",
|
||||||
stack: "water",
|
|
||||||
xAxisID: compare ? "xAxisCompare" : undefined,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
@ -64,6 +64,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
getGridOptions(): LovelaceGridOptions {
|
getGridOptions(): LovelaceGridOptions {
|
||||||
return {
|
return {
|
||||||
columns: 12,
|
columns: 12,
|
||||||
|
rows: 6,
|
||||||
min_columns: 6,
|
min_columns: 6,
|
||||||
min_rows: (this._config?.entities?.length || 1) * 2,
|
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}
|
.minYAxis=${this._config.min_y_axis}
|
||||||
.maxYAxis=${this._config.max_y_axis}
|
.maxYAxis=${this._config.max_y_axis}
|
||||||
.fitYData=${this._config.fit_y_data || false}
|
.fitYData=${this._config.fit_y_data || false}
|
||||||
|
height="100%"
|
||||||
></state-history-charts>
|
></state-history-charts>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
@ -289,6 +291,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
ha-card {
|
ha-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
@ -302,10 +306,14 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.has-header {
|
.has-header {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
state-history-charts {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,6 +131,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
getGridOptions(): LovelaceGridOptions {
|
getGridOptions(): LovelaceGridOptions {
|
||||||
return {
|
return {
|
||||||
columns: 12,
|
columns: 12,
|
||||||
|
rows: 5,
|
||||||
min_columns: 8,
|
min_columns: 8,
|
||||||
min_rows: 4,
|
min_rows: 4,
|
||||||
};
|
};
|
||||||
@ -277,6 +278,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
.fitYData=${this._config.fit_y_data || false}
|
.fitYData=${this._config.fit_y_data || false}
|
||||||
.hideLegend=${this._config.hide_legend || false}
|
.hideLegend=${this._config.hide_legend || false}
|
||||||
.logarithmicScale=${this._config.logarithmic_scale || false}
|
.logarithmicScale=${this._config.logarithmic_scale || false}
|
||||||
|
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
|
||||||
|
height="100%"
|
||||||
></statistics-chart>
|
></statistics-chart>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
@ -352,14 +355,20 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
ha-card {
|
ha-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.has-header {
|
.has-header {
|
||||||
padding-top: 0;
|
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",
|
"duration": "Duration",
|
||||||
"source_history": "Source: History",
|
"source_history": "Source: History",
|
||||||
"source_stats": "Source: Long term statistics",
|
"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"
|
"zoom_reset": "Reset zoom"
|
||||||
},
|
},
|
||||||
"map": {
|
"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
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@leichtgewicht/ip-codec@npm:^2.0.1":
|
||||||
version: 2.0.5
|
version: 2.0.5
|
||||||
resolution: "@leichtgewicht/ip-codec@npm:2.0.5"
|
resolution: "@leichtgewicht/ip-codec@npm:2.0.5"
|
||||||
@ -4650,7 +4643,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/hammerjs@npm:^2.0.36, @types/hammerjs@npm:^2.0.45":
|
"@types/hammerjs@npm:^2.0.36":
|
||||||
version: 2.0.46
|
version: 2.0.46
|
||||||
resolution: "@types/hammerjs@npm:2.0.46"
|
resolution: "@types/hammerjs@npm:2.0.46"
|
||||||
checksum: 10/1b6502d668f45ca49fb488c01f7938d3aa75e989d70c64801c8feded7d659ca1a118f745c1b604d220efe344c93231767d5cc68c05e00e069c14539b6143cfd9
|
checksum: 10/1b6502d668f45ca49fb488c01f7938d3aa75e989d70c64801c8feded7d659ca1a118f745c1b604d220efe344c93231767d5cc68c05e00e069c14539b6143cfd9
|
||||||
@ -6434,27 +6427,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"check-error@npm:^2.1.1":
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
resolution: "check-error@npm:2.1.1"
|
resolution: "check-error@npm:2.1.1"
|
||||||
@ -7355,6 +7327,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ee-first@npm:1.1.1":
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
resolution: "ee-first@npm:1.1.1"
|
resolution: "ee-first@npm:1.1.1"
|
||||||
@ -9038,13 +9020,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"handle-thing@npm:^2.0.0":
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
resolution: "handle-thing@npm:2.0.1"
|
resolution: "handle-thing@npm:2.0.1"
|
||||||
@ -9253,8 +9228,6 @@ __metadata:
|
|||||||
babel-plugin-template-html-minifier: "npm:4.1.0"
|
babel-plugin-template-html-minifier: "npm:4.1.0"
|
||||||
barcode-detector: "npm:2.3.1"
|
barcode-detector: "npm:2.3.1"
|
||||||
browserslist-useragent-regexp: "npm:4.1.3"
|
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"
|
color-name: "npm:2.0.0"
|
||||||
comlink: "npm:4.4.2"
|
comlink: "npm:4.4.2"
|
||||||
core-js: "npm:3.40.0"
|
core-js: "npm:3.40.0"
|
||||||
@ -9265,6 +9238,7 @@ __metadata:
|
|||||||
deep-freeze: "npm:0.0.1"
|
deep-freeze: "npm:0.0.1"
|
||||||
del: "npm:8.0.0"
|
del: "npm:8.0.0"
|
||||||
dialog-polyfill: "npm:0.5.6"
|
dialog-polyfill: "npm:0.5.6"
|
||||||
|
echarts: "npm:5.6.0"
|
||||||
element-internals-polyfill: "npm:1.3.12"
|
element-internals-polyfill: "npm:1.3.12"
|
||||||
eslint: "npm:9.18.0"
|
eslint: "npm:9.18.0"
|
||||||
eslint-config-airbnb-base: "npm:15.0.0"
|
eslint-config-airbnb-base: "npm:15.0.0"
|
||||||
@ -14179,7 +14153,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 2.8.1
|
||||||
resolution: "tslib@npm:2.8.1"
|
resolution: "tslib@npm:2.8.1"
|
||||||
checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7
|
checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7
|
||||||
@ -15922,6 +15896,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"zxing-wasm@npm:1.3.4":
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
resolution: "zxing-wasm@npm:1.3.4"
|
resolution: "zxing-wasm@npm:1.3.4"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user