diff --git a/package.json b/package.json index ac4f978948..3b7fcc5eaa 100644 --- a/package.json +++ b/package.json @@ -99,8 +99,6 @@ "@webcomponents/webcomponentsjs": "2.8.0", "app-datepicker": "5.1.1", "barcode-detector": "2.3.1", - "chart.js": "4.4.7", - "chartjs-plugin-zoom": "2.2.0", "color-name": "2.0.0", "comlink": "4.4.2", "core-js": "3.40.0", @@ -110,6 +108,7 @@ "deep-clone-simple": "1.1.1", "deep-freeze": "0.0.1", "dialog-polyfill": "0.5.6", + "echarts": "5.6.0", "element-internals-polyfill": "1.3.12", "fuse.js": "7.0.0", "google-timezones-json": "1.2.0", @@ -239,7 +238,8 @@ "clean-css": "5.3.3", "@lit/reactive-element": "1.6.3", "@fullcalendar/daygrid": "6.1.15", - "globals": "15.14.0" + "globals": "15.14.0", + "tslib": "^2.3.0" }, "packageManager": "yarn@4.6.0" } diff --git a/src/components/chart/axis-label.ts b/src/components/chart/axis-label.ts new file mode 100644 index 0000000000..d779269e33 --- /dev/null +++ b/src/components/chart/axis-label.ts @@ -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, + }; +} diff --git a/src/components/chart/chart-date-adapter.ts b/src/components/chart/chart-date-adapter.ts deleted file mode 100644 index 875282985a..0000000000 --- a/src/components/chart/chart-date-adapter.ts +++ /dev/null @@ -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; - } - }, -}); diff --git a/src/components/chart/click_is_touch.ts b/src/components/chart/click_is_touch.ts deleted file mode 100644 index a2ccfeb5e7..0000000000 --- a/src/components/chart/click_is_touch.ts +++ /dev/null @@ -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"); diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index cf315f9a45..ccd628260a 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -1,266 +1,150 @@ -import type { - Chart, - ChartType, - ChartData, - ChartOptions, - TooltipModel, - UpdateMode, -} from "chart.js"; import type { PropertyValues } from "lit"; import { css, html, nothing, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { mdiRestart } from "@mdi/js"; +import type { EChartsType } from "echarts/core"; +import type { DataZoomComponentOption } from "echarts/components"; +import { ResizeController } from "@lit-labs/observers/resize-controller"; +import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared"; import { fireEvent } from "../../common/dom/fire_event"; -import { clamp } from "../../common/number/clamp"; import type { HomeAssistant } from "../../types"; -import { debounce } from "../../common/util/debounce"; import { isMac } from "../../util/is_mac"; import "../ha-icon-button"; +import type { ECOption } from "../../resources/echarts"; +import { listenMediaQuery } from "../../common/dom/media_query"; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; -interface Tooltip - extends Omit, "tooltipPosition" | "hasValue" | "getProps"> { - top: string; - left: string; -} - -export interface ChartDatasetExtra { - show_legend?: boolean; - legend_label?: string; -} - @customElement("ha-chart-base") export class HaChartBase extends LitElement { - public chart?: Chart; + public chart?: EChartsType; @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: "chart-type", reflect: true }) - public chartType: ChartType = "line"; + @property({ attribute: false }) public data: ECOption["series"] = []; - @property({ attribute: false }) public data: ChartData = { datasets: [] }; + @property({ attribute: false }) public options?: ECOption; - @property({ attribute: false }) public extraData?: ChartDatasetExtra[]; - - @property({ attribute: false }) public options?: ChartOptions; - - @property({ attribute: false }) public plugins?: any[]; - - @property({ type: Number }) public height?: number; - - @property({ attribute: false, type: Number }) public paddingYAxis = 0; + @property({ type: String }) public height?: string; @property({ attribute: "external-hidden", type: Boolean }) public externalHidden = false; - @state() private _legendHeight?: number; - - @state() private _tooltip?: Tooltip; - - @state() private _hiddenDatasets = new Set(); - - @state() private _showZoomHint = false; - @state() private _isZoomed = false; - private _paddingUpdateCount = 0; + private _modifierPressed = false; - private _paddingUpdateLock = false; + private _isTouchDevice = "ontouchstart" in window; - private _paddingYAxisInternal = 0; + // @ts-ignore + private _resizeController = new ResizeController(this, { + callback: () => this.chart?.resize(), + }); - private _datasetOrder: number[] = []; + private _loading = false; + + private _reducedMotion = false; + + private _listeners: (() => void)[] = []; public disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("scroll", this._handleScroll, true); - this._releaseCanvas(); + while (this._listeners.length) { + this._listeners.pop()!(); + } + this.chart?.dispose(); } public connectedCallback() { super.connectedCallback(); - window.addEventListener("scroll", this._handleScroll, true); if (this.hasUpdated) { - this._releaseCanvas(); this._setupChart(); } - } - public updateChart = (mode?: UpdateMode): void => { - this.chart?.update(mode); - }; + this._listeners.push( + listenMediaQuery("(prefers-reduced-motion)", (matches) => { + this._reducedMotion = matches; + this.chart?.setOption({ animation: !this._reducedMotion }); + }) + ); + + // Add keyboard event listeners + const handleKeyDown = (ev: KeyboardEvent) => { + if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) { + this._modifierPressed = true; + if (!this.options?.dataZoom) { + this.chart?.setOption({ + dataZoom: this._getDataZoomConfig(), + }); + } + } + }; + + const handleKeyUp = (ev: KeyboardEvent) => { + if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) { + this._modifierPressed = false; + if (!this.options?.dataZoom) { + this.chart?.setOption({ + dataZoom: this._getDataZoomConfig(), + }); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + this._listeners.push( + () => window.removeEventListener("keydown", handleKeyDown), + () => window.removeEventListener("keyup", handleKeyUp) + ); + } protected firstUpdated() { this._setupChart(); - this.data.datasets.forEach((dataset, index) => { - if (dataset.hidden) { - this._hiddenDatasets.add(index); - } - }); } - public shouldUpdate(changedProps: PropertyValues): boolean { - if ( - this._paddingUpdateLock && - changedProps.size === 1 && - changedProps.has("paddingYAxis") - ) { - return false; - } - return true; - } - - private _debouncedClearUpdates = debounce( - () => { - this._paddingUpdateCount = 0; - }, - 2000, - false - ); - public willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); - if (!this._paddingUpdateLock) { - this._paddingYAxisInternal = this.paddingYAxis; - if (changedProps.size === 1 && changedProps.has("paddingYAxis")) { - this._paddingUpdateCount++; - if (this._paddingUpdateCount > 300) { - this._paddingUpdateLock = true; - // eslint-disable-next-line - console.error( - "Detected excessive chart padding updates, possibly an infinite loop. Disabling axis padding." - ); - } else { - this._debouncedClearUpdates(); - } - } - } - - // put the legend labels in sorted order if provided - if (changedProps.has("data")) { - this._datasetOrder = this.data.datasets.map((_, index) => index); - if (this.data?.datasets.some((dataset) => dataset.order)) { - this._datasetOrder.sort( - (a, b) => - (this.data.datasets[a].order || 0) - - (this.data.datasets[b].order || 0) - ); - } - - if (this.externalHidden) { - this._hiddenDatasets = new Set(); - if (this.data?.datasets) { - this.data.datasets.forEach((dataset, index) => { - if (dataset.hidden) { - this._hiddenDatasets.add(index); - } - }); - } - } - } - if (!this.hasUpdated || !this.chart) { return; } - if (changedProps.has("plugins") || changedProps.has("chartType")) { - this._releaseCanvas(); - this._setupChart(); - return; - } if (changedProps.has("data")) { - if (this._hiddenDatasets.size && !this.externalHidden) { - this.data.datasets.forEach((dataset, index) => { - dataset.hidden = this._hiddenDatasets.has(index); - }); - } - this.chart.data = this.data; + this.chart.setOption( + { series: this.data }, + { lazyUpdate: true, replaceMerge: ["series"] } + ); } - if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) { - // this resets the chart zoom because min/max scales changed - // so we only do it if the user is not zooming or panning - this.chart.options = this._createOptions(); - } - this.chart.update("none"); - } - - protected updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - if (changedProperties.has("data") || changedProperties.has("options")) { - if (this.options?.plugins?.legend?.display) { - this._legendHeight = - this.renderRoot.querySelector(".chart-legend")?.clientHeight; - } else { - this._legendHeight = 0; - } + if (changedProps.has("options") || changedProps.has("_isZoomed")) { + this.chart.setOption(this._createOptions(), { + lazyUpdate: true, + // if we replace the whole object, it will reset the dataZoom + replaceMerge: [ + "xAxis", + "yAxis", + "dataZoom", + "dataset", + "tooltip", + "legend", + "grid", + "visualMap", + ], + }); } } protected render() { return html` - ${this.options?.plugins?.legend?.display === true - ? html`
-
    - ${this._datasetOrder.map((index) => { - const dataset = this.data.datasets[index]; - return this.extraData?.[index]?.show_legend === false - ? nothing - : html`
  • -
    -
    - ${this.extraData?.[index]?.legend_label ?? - dataset.label} -
    -
  • `; - })} -
-
` - : ""}
- -
-
- ${isMac - ? this.hass.localize("ui.components.history_charts.zoom_hint_mac") - : this.hass.localize("ui.components.history_charts.zoom_hint")} -
-
- ${this._isZoomed && this.chartType !== "timeline" +
+ ${this._isZoomed ? html`` : nothing} - ${this._tooltip - ? html`
-
${this._tooltip.title}
- ${this._tooltip.beforeBody - ? html`
- ${this._tooltip.beforeBody} -
` - : ""} -
-
    - ${this._tooltip.body.map( - (item, i) => - html`
  • -
    - ${item.lines.join("\n")} -
  • ` - )} -
-
- ${this._tooltip.footer.length - ? html`` - : ""} -
` - : ""}
`; } - private _loading = false; - private async _setupChart() { if (this._loading) return; - const ctx: CanvasRenderingContext2D = this.renderRoot - .querySelector("canvas")! - .getContext("2d")!; + const container = this.renderRoot.querySelector(".chart") as HTMLDivElement; this._loading = true; try { - // eslint-disable-next-line @typescript-eslint/naming-convention - const ChartConstructor = (await import("../../resources/chartjs")).Chart; + const echarts = (await import("../../resources/echarts")).default; - const computedStyles = getComputedStyle(this); - - ChartConstructor.defaults.borderColor = - computedStyles.getPropertyValue("--divider-color"); - ChartConstructor.defaults.color = computedStyles.getPropertyValue( - "--secondary-text-color" - ); - ChartConstructor.defaults.font.family = - computedStyles.getPropertyValue("--mdc-typography-body1-font-family") || - computedStyles.getPropertyValue("--mdc-typography-font-family") || - "Roboto, Noto, sans-serif"; - - this.chart = new ChartConstructor(ctx, { - type: this.chartType, - data: this.data, - options: this._createOptions(), - plugins: this._createPlugins(), + this.chart = echarts.init(container); + this.chart.on("legendselectchanged", (params: any) => { + if (this.externalHidden) { + const isSelected = params.selected[params.name]; + if (isSelected) { + fireEvent(this, "dataset-unhidden", { name: params.name }); + } else { + fireEvent(this, "dataset-hidden", { name: params.name }); + } + } }); + this.chart.on("datazoom", (e: any) => { + const { start, end } = e.batch?.[0] ?? e; + this._isZoomed = start !== 0 || end !== 100; + }); + this.chart.setOption({ ...this._createOptions(), series: this.data }); } finally { this._loading = false; } } - private _createOptions(): ChartOptions { - const modifierKey = isMac ? "meta" : "ctrl"; + private _getDataZoomConfig(): DataZoomComponentOption | undefined { + const xAxis = (this.options?.xAxis?.[0] ?? + this.options?.xAxis) as XAXisOption; + const yAxis = (this.options?.yAxis?.[0] ?? + this.options?.yAxis) as YAXisOption; + if (xAxis.type === "value" && yAxis.type === "category") { + // vertical data zoom doesn't work well in this case and horizontal is pointless + return undefined; + } return { - maintainAspectRatio: false, - animation: { - duration: 500, - }, - ...this.options, - plugins: { - ...this.options?.plugins, - tooltip: { - ...this.options?.plugins?.tooltip, - enabled: false, - external: (context) => this._handleTooltip(context), - }, - legend: { - ...this.options?.plugins?.legend, - display: false, - }, - zoom: { - ...this.options?.plugins?.zoom, - pan: { - enabled: true, - }, - zoom: { - pinch: { - enabled: true, - }, - drag: { - enabled: true, - modifierKey, - threshold: 2, - }, - wheel: { - enabled: true, - modifierKey, - speed: 0.05, - }, - mode: - this.chartType !== "timeline" && - (this.options?.scales?.y as any)?.type === "category" - ? "y" - : "x", - onZoomComplete: () => { - const isZoomed = this.chart?.isZoomedOrPanned() ?? false; - if (this._isZoomed && !isZoomed) { - setTimeout(() => { - // make sure the scales are properly reset after full zoom out - // they get bugged when zooming in/out multiple times and panning - this.chart?.resetZoom(); - }); - } - this._isZoomed = isZoomed; - }, - }, - limits: { - x: { - min: "original", - max: (this.options?.scales?.x as any)?.max ?? "original", - }, - y: { - min: "original", - max: "original", - }, - }, - }, - }, + id: "dataZoom", + type: "inside", + orient: "horizontal", + filterMode: "none", + moveOnMouseMove: this._isZoomed, + preventDefaultMouseMove: this._isZoomed, + zoomLock: !this._isTouchDevice && !this._modifierPressed, }; } - private _createPlugins() { - return [ - ...(this.plugins || []), - { - id: "resizeHook", - resize: (chart: Chart) => { - if (!this.height) { - // lock the height - // this removes empty space below the chart - this.height = chart.height; - } - }, - legend: { - ...this.options?.plugins?.legend, - display: false, - }, + private _createOptions(): ECOption { + const darkMode = this.hass.themes?.darkMode ?? false; + const xAxis = Array.isArray(this.options?.xAxis) + ? this.options?.xAxis + : [this.options?.xAxis]; + const yAxis = Array.isArray(this.options?.yAxis) + ? this.options?.yAxis + : [this.options?.yAxis]; + // we should create our own theme but this is a quick fix for now + const splitLineStyle = darkMode ? { color: "#333" } : {}; + + const options = { + animation: !this._reducedMotion, + darkMode, + aria: { + show: true, }, - ]; + dataZoom: this._getDataZoomConfig(), + ...this.options, + xAxis: xAxis.map((axis) => + axis + ? { + ...axis, + splitLine: axis.splitLine + ? { + ...axis.splitLine, + lineStyle: splitLineStyle, + } + : undefined, + } + : undefined + ) as XAXisOption[], + yAxis: yAxis.map((axis) => + axis + ? { + ...axis, + splitLine: axis.splitLine + ? { + ...axis.splitLine, + lineStyle: splitLineStyle, + } + : undefined, + } + : undefined + ) as YAXisOption[], + }; + + const isMobile = window.matchMedia( + "all and (max-width: 450px), all and (max-height: 500px)" + ).matches; + if (isMobile && options.tooltip) { + // mobile charts are full width so we need to confine the tooltip to the chart + const tooltips = Array.isArray(options.tooltip) + ? options.tooltip + : [options.tooltip]; + tooltips.forEach((tooltip) => { + tooltip.confine = true; + tooltip.appendTo = undefined; + }); + options.tooltip = tooltips; + } + return options; } private _getDefaultHeight() { - return this.clientWidth / 2; - } - - private _handleChartScroll(ev: MouseEvent) { - const modifier = isMac ? "metaKey" : "ctrlKey"; - this._tooltip = undefined; - if (!ev[modifier] && !this._showZoomHint) { - this._showZoomHint = true; - setTimeout(() => { - this._showZoomHint = false; - }, 1000); - } - } - - private _legendClick(ev) { - if (!this.chart) { - return; - } - const index = ev.currentTarget.datasetIndex; - if (this.chart.isDatasetVisible(index)) { - this.chart.setDatasetVisibility(index, false); - this._hiddenDatasets.add(index); - if (this.externalHidden) { - fireEvent(this, "dataset-hidden", { - index, - }); - } - } else { - this.chart.setDatasetVisibility(index, true); - this._hiddenDatasets.delete(index); - if (this.externalHidden) { - fireEvent(this, "dataset-unhidden", { - index, - }); - } - } - this.chart.update("none"); - this.requestUpdate("_hiddenDatasets"); - } - - private _handleTooltip(context: { - chart: Chart; - tooltip: TooltipModel; - }) { - if (context.tooltip.opacity === 0) { - this._tooltip = undefined; - return; - } - const boundingBox = this.getBoundingClientRect(); - this._tooltip = { - ...context.tooltip, - top: - boundingBox.y + - (this._legendHeight || 0) + - context.tooltip.caretY + - 12 + - "px", - left: - clamp( - boundingBox.x + context.tooltip.caretX, - boundingBox.x + 100, - boundingBox.x + boundingBox.width - 100 - ) - - 100 + - "px", - }; - } - - private _releaseCanvas() { - // release the canvas memory to prevent - // safari from running out of memory. - if (this.chart) { - this.chart.destroy(); - } + return Math.max(this.clientWidth / 2, 400); } private _handleZoomReset() { - this.chart?.resetZoom(); + this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); } - private _handleScroll = () => { - this._tooltip = undefined; - }; + private _handleWheel(e: WheelEvent) { + // if the window is not focused, we don't receive the keydown events but scroll still works + if (!this.options?.dataZoom) { + const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey); + if (modifierPressed) { + e.preventDefault(); + } + if (modifierPressed !== this._modifierPressed) { + this._modifierPressed = modifierPressed; + this.chart?.setOption({ + dataZoom: this._getDataZoomConfig(), + }); + } + } + } static styles = css` :host { @@ -533,124 +301,11 @@ export class HaChartBase extends LitElement { } .chart-container { position: relative; - } - canvas { max-height: var(--chart-max-height, 400px); } - canvas.not-zoomed { - /* allow scrolling if the chart is not zoomed */ - touch-action: pan-y !important; - } - .chart-legend { - text-align: center; - } - .chart-legend li { - cursor: pointer; - display: inline-grid; - grid-auto-flow: column; - padding: 0 8px; - box-sizing: border-box; - align-items: center; - color: var(--secondary-text-color); - } - .chart-legend .hidden { - text-decoration: line-through; - } - .chart-legend .label { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - .chart-legend .bullet, - .chart-tooltip .bullet { - border-width: 1px; - border-style: solid; - border-radius: 50%; - display: inline-block; - height: 16px; - margin-right: 6px; - width: 16px; - flex-shrink: 0; - box-sizing: border-box; - margin-inline-end: 6px; - margin-inline-start: initial; - direction: var(--direction); - } - .chart-tooltip .bullet { - align-self: baseline; - } - .chart-tooltip { - padding: 8px; - font-size: 90%; - position: fixed; - background: rgba(80, 80, 80, 0.9); - color: white; - border-radius: 4px; - pointer-events: none; - z-index: 1; - -ms-user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - width: 200px; - box-sizing: border-box; - direction: var(--direction); - } - .chart-legend ul, - .chart-tooltip ul { - display: inline-block; - padding: 0 0px; - margin: 8px 0 0 0; + .chart { width: 100%; - } - .chart-tooltip ul { - margin: 0 4px; - } - .chart-tooltip li { - display: flex; - white-space: pre-line; - word-break: break-word; - align-items: center; - line-height: 16px; - padding: 4px 0; - } - .chart-tooltip .title { - text-align: center; - font-weight: 500; - word-break: break-word; - direction: ltr; - } - .chart-tooltip .footer { - font-weight: 500; - } - .chart-tooltip .before-body { - text-align: center; - font-weight: 300; - word-break: break-all; - } - .zoom-hint { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: none; - } - .zoom-hint.visible { - opacity: 1; - } - .zoom-hint > div { - color: white; - font-size: 1.5em; - font-weight: 500; - padding: 8px; - border-radius: 8px; - background: rgba(0, 0, 0, 0.3); - box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3); + height: 100%; } .zoom-reset { position: absolute; @@ -670,7 +325,7 @@ declare global { "ha-chart-base": HaChartBase; } interface HASSDomEvents { - "dataset-hidden": { index: number }; - "dataset-unhidden": { index: number }; + "dataset-hidden": { name: string }; + "dataset-unhidden": { name: string }; } } diff --git a/src/components/chart/ha-sankey-chart.ts b/src/components/chart/ha-sankey-chart.ts index bbe8a7f424..375c7bc5c1 100644 --- a/src/components/chart/ha-sankey-chart.ts +++ b/src/components/chart/ha-sankey-chart.ts @@ -3,6 +3,7 @@ import { LitElement, html, css, svg, nothing } from "lit"; import { ResizeController } from "@lit-labs/observers/resize-controller"; import memoizeOne from "memoize-one"; import type { HomeAssistant } from "../../types"; +import { measureTextWidth } from "../../util/text"; export interface Node { id: string; @@ -68,15 +69,12 @@ export class HaSankeyChart extends LitElement { private _statePerPixel = 0; - private _textMeasureCanvas?: HTMLCanvasElement; - private _sizeController = new ResizeController(this, { callback: (entries) => entries[0]?.contentRect, }); disconnectedCallback() { super.disconnectedCallback(); - this._textMeasureCanvas = undefined; } willUpdate() { @@ -477,7 +475,7 @@ export class HaSankeyChart extends LitElement { (node) => NODE_WIDTH + TEXT_PADDING + - (node.label ? this._getTextWidth(node.label) : 0) + (node.label ? measureTextWidth(node.label, FONT_SIZE) : 0) ) ) : 0; @@ -492,18 +490,6 @@ export class HaSankeyChart extends LitElement { : fullSize / nodesPerSection.length; } - private _getTextWidth(text: string): number { - if (!this._textMeasureCanvas) { - this._textMeasureCanvas = document.createElement("canvas"); - } - const context = this._textMeasureCanvas.getContext("2d"); - if (!context) return 0; - - // Match the font style from CSS - context.font = `${FONT_SIZE}px sans-serif`; - return context.measureText(text).width; - } - private _getVerticalLabelFontSize(label: string, labelWidth: number): number { // reduce the label font size so the longest word fits on one line const longestWord = label @@ -513,7 +499,7 @@ export class HaSankeyChart extends LitElement { longest.length > current.length ? longest : current, "" ); - const wordWidth = this._getTextWidth(longestWord); + const wordWidth = measureTextWidth(longestWord, FONT_SIZE); return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE); } diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 3380ab116a..e849327ac0 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -1,19 +1,26 @@ -import type { ChartData, ChartDataset, ChartOptions } from "chart.js"; import type { PropertyValues } from "lit"; import { html, LitElement } from "lit"; import { property, state } from "lit/decorators"; +import type { VisualMapComponentOption } from "echarts/components"; +import type { LineSeriesOption } from "echarts/charts"; +import type { YAXisOption } from "echarts/types/dist/shared"; +import { differenceInDays } from "date-fns"; +import { styleMap } from "lit/directives/style-map"; import { getGraphColorByIndex } from "../../common/color/colors"; -import { fireEvent } from "../../common/dom/fire_event"; import { computeRTL } from "../../common/util/compute_rtl"; -import { - formatNumber, - numberFormatToLocale, - getNumberFormatOptions, -} from "../../common/number/format_number"; + import type { LineChartEntity, LineChartState } from "../../data/history"; import type { HomeAssistant } from "../../types"; import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; -import { clickIsTouch } from "./click_is_touch"; +import type { ECOption } from "../../resources/echarts"; +import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; +import { + getNumberFormatOptions, + formatNumber, +} from "../../common/number/format_number"; +import { getTimeAxisLabelConfig } from "./axis-label"; +import { measureTextWidth } from "../../util/text"; +import { fireEvent } from "../../common/dom/fire_event"; const safeParseFloat = (value) => { const parsed = parseFloat(value); @@ -54,15 +61,17 @@ export class StateHistoryChartLine extends LitElement { @property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false; - @state() private _chartData?: ChartData<"line">; + @property({ type: String }) public height?: string; + + @state() private _chartData: LineSeriesOption[] = []; @state() private _entityIds: string[] = []; private _datasetToDataIndex: number[] = []; - @state() private _chartOptions?: ChartOptions; + @state() private _chartOptions?: ECOption; - @state() private _yWidth = 0; + @state() private _yWidth = 25; private _chartTime: Date = new Date(); @@ -72,171 +81,54 @@ export class StateHistoryChartLine extends LitElement { .hass=${this.hass} .data=${this._chartData} .options=${this._chartOptions} - .paddingYAxis=${this.paddingYAxis - this._yWidth} - chart-type="line" + .height=${this.height} + style=${styleMap({ height: this.height })} > `; } + 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 += "
     "; + 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 + ) + "
" + : ""; + return `${time}${param.marker} ${param.seriesName}: ${value} + `; + }) + .join("
"); + } + public willUpdate(changedProps: PropertyValues) { - if ( - !this.hasUpdated || - changedProps.has("showNames") || - changedProps.has("startTime") || - changedProps.has("endTime") || - changedProps.has("unit") || - changedProps.has("logarithmicScale") || - changedProps.has("minYAxis") || - changedProps.has("maxYAxis") || - changedProps.has("fitYData") - ) { - this._chartOptions = { - parsing: false, - interaction: { - mode: "nearest", - axis: "xy", - }, - scales: { - x: { - type: "time", - adapters: { - date: { - locale: this.hass.locale, - config: this.hass.config, - }, - }, - min: this.startTime, - max: this.endTime, - ticks: { - maxRotation: 0, - sampleSize: 5, - autoSkipPadding: 20, - major: { - enabled: true, - }, - font: (context) => - context.tick && context.tick.major - ? ({ weight: "bold" } as any) - : {}, - }, - time: { - tooltipFormat: "datetimeseconds", - }, - }, - y: { - suggestedMin: this.fitYData ? this.minYAxis : null, - suggestedMax: this.fitYData ? this.maxYAxis : null, - min: this.fitYData ? null : this.minYAxis, - max: this.fitYData ? null : this.maxYAxis, - ticks: { - maxTicksLimit: 7, - }, - title: { - display: true, - text: this.unit, - }, - afterUpdate: (y) => { - if (this._yWidth !== Math.floor(y.width)) { - this._yWidth = Math.floor(y.width); - fireEvent(this, "y-width-changed", { - value: this._yWidth, - chartIndex: this.chartIndex, - }); - } - }, - position: computeRTL(this.hass) ? "right" : "left", - type: this.logarithmicScale ? "logarithmic" : "linear", - }, - }, - plugins: { - tooltip: { - callbacks: { - label: (context) => { - let label = `${context.dataset.label}: ${formatNumber( - context.parsed.y, - this.hass.locale, - getNumberFormatOptions( - undefined, - this.hass.entities[this._entityIds[context.datasetIndex]] - ) - )} ${this.unit}`; - const dataIndex = - this._datasetToDataIndex[context.datasetIndex]; - const data = this.data[dataIndex]; - if (data.statistics && data.statistics.length > 0) { - const source = - data.states.length === 0 || - context.parsed.x < data.states[0].last_changed - ? `\n${this.hass.localize( - "ui.components.history_charts.source_stats" - )}` - : `\n${this.hass.localize( - "ui.components.history_charts.source_history" - )}`; - label += source; - } - return label; - }, - }, - }, - filler: { - propagate: true, - }, - legend: { - display: this.showNames, - labels: { - usePointStyle: true, - }, - }, - }, - elements: { - line: { - tension: 0.1, - borderWidth: 1.5, - }, - point: { - hitRadius: 50, - }, - }, - segment: { - borderColor: (context) => { - // render stat data with a slightly transparent line - const dataIndex = this._datasetToDataIndex[context.datasetIndex]; - const data = this.data[dataIndex]; - return data.statistics && - data.statistics.length > 0 && - (data.states.length === 0 || - context.p0.parsed.x < data.states[0].last_changed) - ? this._chartData!.datasets[dataIndex].borderColor + "7F" - : undefined; - }, - }, - // @ts-expect-error - locale: numberFormatToLocale(this.hass.locale), - onClick: (e: any) => { - if (!this.clickForMoreInfo || clickIsTouch(e)) { - return; - } - - const chart = e.chart; - - const points = chart.getElementsAtEventForMode( - e, - "nearest", - { intersect: true }, - true - ); - - if (points.length) { - const firstPoint = points[0]; - fireEvent(this, "hass-more-info", { - entityId: this._entityIds[firstPoint.datasetIndex], - }); - chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip - } - }, - }; - } if ( changedProps.has("data") || changedProps.has("startTime") || @@ -248,13 +140,130 @@ export class StateHistoryChartLine extends LitElement { // so the X axis grows even if there is no new data this._generateData(); } + + if ( + !this.hasUpdated || + changedProps.has("showNames") || + changedProps.has("startTime") || + changedProps.has("endTime") || + changedProps.has("unit") || + changedProps.has("logarithmicScale") || + changedProps.has("minYAxis") || + changedProps.has("maxYAxis") || + changedProps.has("fitYData") || + changedProps.has("_chartData") || + changedProps.has("paddingYAxis") || + changedProps.has("_yWidth") + ) { + const dayDifference = differenceInDays(this.endTime, this.startTime); + const rtl = computeRTL(this.hass); + const splitLineStyle = this.hass.themes?.darkMode + ? { opacity: 0.15 } + : {}; + this._chartOptions = { + xAxis: { + type: "time", + min: this.startTime, + max: this.endTime, + axisLabel: getTimeAxisLabelConfig( + this.hass.locale, + this.hass.config, + dayDifference + ), + axisLine: { + show: false, + }, + splitLine: { + show: true, + lineStyle: splitLineStyle, + }, + minInterval: + dayDifference >= 89 // quarter + ? 28 * 3600 * 24 * 1000 + : dayDifference > 2 + ? 3600 * 24 * 1000 + : undefined, + }, + yAxis: { + type: this.logarithmicScale ? "log" : "value", + name: this.unit, + min: this.fitYData ? this.minYAxis : undefined, + max: this.fitYData ? this.maxYAxis : undefined, + position: rtl ? "right" : "left", + scale: true, + nameGap: 3, + splitLine: { + show: true, + lineStyle: splitLineStyle, + }, + axisLabel: { + margin: 5, + formatter: (value: number) => { + const label = formatNumber(value, this.hass.locale); + const width = measureTextWidth(label, 12) + 5; + if (width > this._yWidth) { + this._yWidth = width; + fireEvent(this, "y-width-changed", { + value: this._yWidth, + chartIndex: this.chartIndex, + }); + } + return label; + }, + }, + } as YAXisOption, + legend: { + show: this.showNames, + icon: "circle", + padding: [20, 0], + }, + grid: { + ...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0 + left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth), + right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1, + bottom: 30, + }, + visualMap: this._chartData + .map((_, seriesIndex) => { + const dataIndex = this._datasetToDataIndex[seriesIndex]; + const data = this.data[dataIndex]; + if (!data.statistics || data.statistics.length === 0) { + return false; + } + // render stat data with a slightly transparent line + const firstStateTS = + data.states[0]?.last_changed ?? this.endTime.getTime(); + return { + show: false, + seriesIndex, + dimension: 0, + pieces: [ + { + max: firstStateTS - 0.01, + colorAlpha: 0.5, + }, + { + min: firstStateTS, + colorAlpha: 1, + }, + ], + }; + }) + .filter(Boolean) as VisualMapComponentOption[], + tooltip: { + trigger: "axis", + appendTo: document.body, + formatter: this._renderTooltip.bind(this), + }, + }; + } } private _generateData() { let colorIndex = 0; const computedStyles = getComputedStyle(this); const entityStates = this.data; - const datasets: ChartDataset<"line">[] = []; + const datasets: LineSeriesOption[] = []; const entityIds: string[] = []; const datasetToDataIndex: number[] = []; if (entityStates.length === 0) { @@ -270,7 +279,7 @@ export class StateHistoryChartLine extends LitElement { // array containing [value1, value2, etc] let prevValues: any[] | null = null; - const data: ChartDataset<"line">[] = []; + const data: LineSeriesOption[] = []; const pushData = (timestamp: Date, datavalues: any[] | null) => { if (!datavalues) return; @@ -287,9 +296,9 @@ export class StateHistoryChartLine extends LitElement { // to the chart for the previous value. Otherwise the gap will // be too big. It will go from the start of the previous data // value until the start of the next data value. - d.data.push({ x: timestamp.getTime(), y: prevValues[i] }); + d.data!.push([timestamp, prevValues[i]]); } - d.data.push({ x: timestamp.getTime(), y: datavalues[i] }); + d.data!.push([timestamp, datavalues[i]]); }); prevValues = datavalues; }; @@ -300,13 +309,25 @@ export class StateHistoryChartLine extends LitElement { colorIndex++; } data.push({ - label: nameY, - fill: fill ? "origin" : false, - borderColor: color, - backgroundColor: color + "7F", - stepped: "before", - pointRadius: 0, + id: nameY, data: [], + type: "line", + name: nameY, + color, + symbol: "circle", + step: "end", + symbolSize: 1, + lineStyle: { + width: fill ? 0 : 1.5, + }, + areaStyle: fill + ? { + color: color + "7F", + } + : undefined, + tooltip: { + show: !fill, + }, }); entityIds.push(states.entity_id); datasetToDataIndex.push(dataIdx); @@ -575,9 +596,7 @@ export class StateHistoryChartLine extends LitElement { Array.prototype.push.apply(datasets, data); }); - this._chartData = { - datasets, - }; + this._chartData = datasets; this._entityIds = entityIds; this._datasetToDataIndex = datasetToDataIndex; } diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index a300309956..4dae7ce189 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -1,19 +1,27 @@ -import type { ChartData, ChartDataset, ChartOptions } from "chart.js"; -import { getRelativePosition } from "chart.js/helpers"; import type { PropertyValues } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; +import type { + CustomSeriesOption, + CustomSeriesRenderItem, + TooltipFormatterCallback, + TooltipPositionCallbackParams, +} from "echarts/types/dist/shared"; +import { differenceInDays } from "date-fns"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration"; -import { fireEvent } from "../../common/dom/fire_event"; -import { numberFormatToLocale } from "../../common/number/format_number"; import { computeRTL } from "../../common/util/compute_rtl"; import type { TimelineEntity } from "../../data/history"; import type { HomeAssistant } from "../../types"; import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; -import type { TimeLineData } from "./timeline-chart/const"; -import { computeTimelineColor } from "./timeline-chart/timeline-color"; -import { clickIsTouch } from "./click_is_touch"; +import { computeTimelineColor } from "./timeline-color"; +import type { ECOption } from "../../resources/echarts"; +import echarts from "../../resources/echarts"; +import { luminosity } from "../../common/color/rgb"; +import { hex2rgb } from "../../common/color/convert-color"; +import { measureTextWidth } from "../../util/text"; +import { fireEvent } from "../../common/dom/fire_event"; +import { getTimeAxisLabelConfig } from "./axis-label"; @customElement("state-history-chart-timeline") export class StateHistoryChartTimeline extends LitElement { @@ -44,9 +52,9 @@ export class StateHistoryChartTimeline extends LitElement { @property({ attribute: false, type: Number }) public chartIndex?; - @state() private _chartData?: ChartData<"timeline">; + @state() private _chartData: CustomSeriesOption[] = []; - @state() private _chartOptions?: ChartOptions<"timeline">; + @state() private _chartOptions?: ECOption; @state() private _yWidth = 0; @@ -56,15 +64,95 @@ export class StateHistoryChartTimeline extends LitElement { return html` `; } + 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 = + (params: TooltipPositionCallbackParams) => { + const { value, name, marker } = Array.isArray(params) + ? params[0] + : params; + const title = `

${value![0]}

`; + 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("
"); + return [title, lines].join(""); + }; + public willUpdate(changedProps: PropertyValues) { if (!this.hasUpdated) { this._createOptions(); @@ -85,7 +173,8 @@ export class StateHistoryChartTimeline extends LitElement { if ( changedProps.has("startTime") || changedProps.has("endTime") || - changedProps.has("showNames") + changedProps.has("showNames") || + changedProps.has("paddingYAxis") ) { this._createOptions(); } @@ -93,144 +182,68 @@ export class StateHistoryChartTimeline extends LitElement { private _createOptions() { const narrow = this.narrow; + const showNames = this.chunked || this.showNames; + const labelWidth = showNames + ? Math.max(narrow ? 70 : 170, this.paddingYAxis) + : 0; + const rtl = computeRTL(this.hass); + const dayDifference = differenceInDays(this.endTime, this.startTime); this._chartOptions = { - maintainAspectRatio: false, - parsing: false, - scales: { - x: { - type: "time", - position: "bottom", - adapters: { - date: { - locale: this.hass.locale, - config: this.hass.config, - }, - }, - min: this.startTime, - suggestedMax: this.endTime, - ticks: { - autoSkip: true, - maxRotation: 0, - sampleSize: 5, - autoSkipPadding: 20, - major: { - enabled: true, - }, - font: (context) => - context.tick && context.tick.major - ? ({ weight: "bold" } as any) - : {}, - }, - grid: { - offset: false, - }, - time: { - tooltipFormat: "datetimeseconds", - }, + xAxis: { + type: "time", + min: this.startTime, + max: this.endTime, + axisLabel: getTimeAxisLabelConfig( + this.hass.locale, + this.hass.config, + dayDifference + ), + minInterval: + dayDifference >= 89 // quarter + ? 28 * 3600 * 24 * 1000 + : dayDifference > 2 + ? 3600 * 24 * 1000 + : undefined, + }, + yAxis: { + type: "category", + inverse: true, + position: rtl ? "right" : "left", + axisTick: { + show: false, }, - y: { - type: "category", - barThickness: 20, - offset: true, - grid: { - display: false, - drawBorder: false, - drawTicks: false, - }, - ticks: { - display: this.chunked || this.showNames, - }, - afterSetDimensions: (y) => { - y.maxWidth = y.chart.width * 0.18; - }, - afterFit: (scaleInstance) => { - if (this.chunked) { - // ensure all the chart labels are the same width - scaleInstance.width = narrow ? 105 : 185; - } - }, - afterUpdate: (y) => { - const yWidth = this.showNames - ? (y.width ?? 0) - : computeRTL(this.hass) - ? 0 - : (y.left ?? 0); - if ( - this._yWidth !== Math.floor(yWidth) && - y.ticks.length === this.data.length - ) { - this._yWidth = Math.floor(yWidth); + axisLine: { + show: false, + }, + axisLabel: { + show: showNames, + width: labelWidth, + overflow: "truncate", + margin: 5, + // @ts-ignore this is a valid option + formatter: (label: string) => { + const width = Math.min(measureTextWidth(label, 12) + 5, labelWidth); + if (width > this._yWidth) { + this._yWidth = width; fireEvent(this, "y-width-changed", { value: this._yWidth, chartIndex: this.chartIndex, }); } + return label; }, - position: computeRTL(this.hass) ? "right" : "left", + hideOverlap: true, }, }, - plugins: { - tooltip: { - mode: "nearest", - callbacks: { - title: (context) => - context![0].chart!.data!.labels![ - context[0].datasetIndex - ] as string, - beforeBody: (context) => context[0].dataset.label || "", - label: (item) => { - const d = item.dataset.data[item.dataIndex] as TimeLineData; - const durationInMs = d.end.getTime() - d.start.getTime(); - const formattedDuration = `${this.hass.localize( - "ui.components.history_charts.duration" - )}: ${millisecondsToDuration(durationInMs)}`; - - return [ - d.label || "", - formatDateTimeWithSeconds( - d.start, - this.hass.locale, - this.hass.config - ), - formatDateTimeWithSeconds( - d.end, - this.hass.locale, - this.hass.config - ), - formattedDuration, - ]; - }, - labelColor: (item) => ({ - borderColor: (item.dataset.data[item.dataIndex] as TimeLineData) - .color!, - backgroundColor: ( - item.dataset.data[item.dataIndex] as TimeLineData - ).color!, - }), - }, - }, - filler: { - propagate: true, - }, + grid: { + top: 10, + bottom: 30, + left: rtl ? 1 : labelWidth, + right: rtl ? labelWidth : 1, }, - // @ts-expect-error - locale: numberFormatToLocale(this.hass.locale), - onClick: (e: any) => { - if (!this.clickForMoreInfo || clickIsTouch(e)) { - return; - } - - const chart = e.chart; - const canvasPosition = getRelativePosition(e, chart); - - const index = Math.abs( - chart.scales.y.getValueForPixel(canvasPosition.y) - ); - fireEvent(this, "hass-more-info", { - // @ts-ignore - entityId: this._chartData?.datasets[index]?.label, - }); - chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip + tooltip: { + appendTo: document.body, + formatter: this._renderTooltip, }, }; } @@ -246,8 +259,7 @@ export class StateHistoryChartTimeline extends LitElement { this._chartTime = new Date(); const startTime = this.startTime; const endTime = this.endTime; - const labels: string[] = []; - const datasets: ChartDataset<"timeline">[] = []; + const datasets: CustomSeriesOption[] = []; const names = this.names || {}; // stateHistory is a list of lists of sorted state objects stateHistory.forEach((stateInfo) => { @@ -258,7 +270,7 @@ export class StateHistoryChartTimeline extends LitElement { const entityDisplay: string = names[stateInfo.entity_id] || stateInfo.name; - const dataRow: TimeLineData[] = []; + const dataRow: unknown[] = []; stateInfo.data.forEach((entityState) => { let newState: string | null = entityState.state; const timeStamp = new Date(entityState.last_changed); @@ -277,15 +289,23 @@ export class StateHistoryChartTimeline extends LitElement { } else if (newState !== prevState) { newLastChanged = new Date(entityState.last_changed); + const color = computeTimelineColor( + prevState, + computedStyles, + this.hass.states[stateInfo.entity_id] + ); dataRow.push({ - start: prevLastChanged, - end: newLastChanged, - label: locState, - color: computeTimelineColor( - prevState, - computedStyles, - this.hass.states[stateInfo.entity_id] - ), + value: [ + entityDisplay, + prevLastChanged, + newLastChanged, + locState, + color, + luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff", + ], + itemStyle: { + color, + }, }); prevState = newState; @@ -295,28 +315,40 @@ export class StateHistoryChartTimeline extends LitElement { }); if (prevState !== null) { + const color = computeTimelineColor( + prevState, + computedStyles, + this.hass.states[stateInfo.entity_id] + ); dataRow.push({ - start: prevLastChanged, - end: endTime, - label: locState, - color: computeTimelineColor( - prevState, - computedStyles, - this.hass.states[stateInfo.entity_id] - ), + value: [ + entityDisplay, + prevLastChanged, + endTime, + locState, + color, + luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff", + ], + itemStyle: { + color, + }, }); } datasets.push({ data: dataRow, - label: stateInfo.entity_id, + name: entityDisplay, + dimensions: ["index", "start", "end", "name", "color", "textColor"], + type: "custom", + encode: { + x: [1, 2], + y: 0, + itemName: 3, + }, + renderItem: this._renderItem, }); - labels.push(entityDisplay); }); - this._chartData = { - labels: labels, - datasets: datasets, - }; + this._chartData = datasets; } static styles = css` diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts index a389307c22..0c20746206 100644 --- a/src/components/chart/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -2,6 +2,7 @@ import type { PropertyValues } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, eventOptions, property, state } from "lit/decorators"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; +import { styleMap } from "lit/directives/style-map"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { restoreScroll } from "../../common/decorators/restore-scroll"; import type { @@ -69,6 +70,8 @@ export class StateHistoryCharts extends LitElement { @property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false; + @property({ type: String }) public height?: string; + private _computedStartTime!: Date; private _computedEndTime!: Date; @@ -133,7 +136,10 @@ export class StateHistoryCharts extends LitElement { return html``; } if (!Array.isArray(item)) { - return html`
+ return html`
`; } diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 180daa3652..78fafaf434 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -1,21 +1,15 @@ -import type { - ChartData, - ChartDataset, - ChartOptions, - ChartType, -} from "chart.js"; import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import type { + BarSeriesOption, + LineSeriesOption, +} from "echarts/types/dist/shared"; +import { styleMap } from "lit/directives/style-map"; import { getGraphColorByIndex } from "../../common/color/colors"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; -import { fireEvent } from "../../common/dom/fire_event"; -import { - formatNumber, - numberFormatToLocale, - getNumberFormatOptions, -} from "../../common/number/format_number"; + import type { Statistics, StatisticsMetaData, @@ -25,13 +19,18 @@ import { getDisplayUnit, getStatisticLabel, getStatisticMetadata, - isExternalStatistic, statisticsHaveType, } from "../../data/recorder"; import type { HomeAssistant } from "../../types"; import "./ha-chart-base"; -import type { ChartDatasetExtra } from "./ha-chart-base"; -import { clickIsTouch } from "./click_is_touch"; +import { computeRTL } from "../../common/util/compute_rtl"; +import type { ECOption } from "../../resources/echarts"; +import { + formatNumber, + getNumberFormatOptions, +} from "../../common/number/format_number"; +import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; +import { getTimeAxisLabelConfig } from "./axis-label"; export const supportedStatTypeMap: Record = { mean: "mean", @@ -62,7 +61,7 @@ export class StatisticsChart extends LitElement { @property({ attribute: false, type: Array }) public statTypes: StatisticType[] = ["sum", "min", "mean", "max"]; - @property({ attribute: false }) public chartType: ChartType = "line"; + @property({ attribute: false }) public chartType: "line" | "bar" = "line"; @property({ attribute: false, type: Number }) public minYAxis?: number; @@ -84,15 +83,18 @@ export class StatisticsChart extends LitElement { @property() public period?: string; - @state() private _chartData: ChartData = { datasets: [] }; + @property({ attribute: "days-to-show", type: Number }) + public daysToShow?: number; - @state() private _chartDatasetExtra: ChartDatasetExtra[] = []; + @property({ type: String }) public height?: string; + + @state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = []; + + @state() private _legendData: string[] = []; @state() private _statisticIds: string[] = []; - @state() private _chartOptions?: ChartOptions; - - @state() private _hiddenStats = new Set(); + @state() private _chartOptions?: ECOption; private _computedStyle?: CSSStyleDeclaration; @@ -101,8 +103,13 @@ export class StatisticsChart extends LitElement { } public willUpdate(changedProps: PropertyValues) { - if (changedProps.has("legendMode")) { - this._hiddenStats.clear(); + if ( + changedProps.has("statisticsData") || + changedProps.has("statTypes") || + changedProps.has("chartType") || + changedProps.has("hideLegend") + ) { + this._generateData(); } if ( !this.hasUpdated || @@ -117,15 +124,6 @@ export class StatisticsChart extends LitElement { ) { this._createOptions(); } - if ( - changedProps.has("statisticsData") || - changedProps.has("statTypes") || - changedProps.has("chartType") || - changedProps.has("hideLegend") || - changedProps.has("_hiddenStats") - ) { - this._generateData(); - } } public firstUpdated() { @@ -157,146 +155,105 @@ export class StatisticsChart extends LitElement { return html` `; } - private _datasetHidden(ev) { - ev.stopPropagation(); - this._hiddenStats.add(this._statisticIds[ev.detail.index]); - this.requestUpdate("_hiddenStats"); - } + private _renderTooltip(params: any) { + return params + .map((param, index: number) => { + const value = `${formatNumber( + // max series has 3 values, as the second value is the max-min to form a band + (param.value[2] ?? param.value[1]) as number, + this.hass.locale, + getNumberFormatOptions( + undefined, + this.hass.entities[this._statisticIds[param.seriesIndex]] + ) + )} ${this.unit}`; - private _datasetUnhidden(ev) { - ev.stopPropagation(); - this._hiddenStats.delete(this._statisticIds[ev.detail.index]); - this.requestUpdate("_hiddenStats"); - } - - private _createOptions(unit?: string) { - this._chartOptions = { - parsing: false, - interaction: { - mode: "nearest", - axis: "x", - }, - scales: { - x: { - type: "time", - adapters: { - date: { - locale: this.hass.locale, - config: this.hass.config, - }, - }, - ticks: { - source: this.chartType === "bar" ? "data" : undefined, - maxRotation: 0, - sampleSize: 5, - autoSkipPadding: 20, - major: { - enabled: true, - }, - font: (context) => - context.tick && context.tick.major - ? ({ weight: "bold" } as any) - : {}, - }, - time: { - tooltipFormat: "datetime", - unit: - this.chartType === "bar" && - this.period && - ["hour", "day", "week", "month"].includes(this.period) - ? this.period - : undefined, - }, - }, - y: { - beginAtZero: this.chartType === "bar", - ticks: { - maxTicksLimit: 7, - }, - title: { - display: unit || this.unit, - text: unit || this.unit, - }, - type: this.logarithmicScale ? "logarithmic" : "linear", - min: this.fitYData ? null : this.minYAxis, - max: this.fitYData ? null : this.maxYAxis, - }, - }, - plugins: { - tooltip: { - callbacks: { - label: (context) => - `${context.dataset.label}: ${formatNumber( - context.parsed.y, + const time = + index === 0 + ? formatDateTimeWithSeconds( + new Date(param.value[0]), this.hass.locale, - getNumberFormatOptions( - undefined, - this.hass.entities[this._statisticIds[context.datasetIndex]] - ) - )} ${ - // @ts-ignore - context.dataset.unit || "" - }`, - }, + this.hass.config + ) + "
" + : ""; + return `${time}${param.marker} ${param.seriesName}: ${value} + `; + }) + .join("
"); + } + + private _createOptions() { + const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {}; + const dayDifference = this.daysToShow ?? 1; + this._chartOptions = { + xAxis: { + type: "time", + axisLabel: getTimeAxisLabelConfig( + this.hass.locale, + this.hass.config, + dayDifference + ), + axisLine: { + show: false, }, - filler: { - propagate: true, + splitLine: { + show: true, + lineStyle: splitLineStyle, }, - legend: { - display: !this.hideLegend, - labels: { - usePointStyle: true, - }, + minInterval: + dayDifference >= 89 // quarter + ? 28 * 3600 * 24 * 1000 + : dayDifference > 2 + ? 3600 * 24 * 1000 + : undefined, + }, + yAxis: { + type: this.logarithmicScale ? "log" : "value", + name: this.unit, + position: computeRTL(this.hass) ? "right" : "left", + // @ts-ignore + scale: this.chartType !== "bar", + min: this.fitYData ? undefined : this.minYAxis, + max: this.fitYData ? undefined : this.maxYAxis, + splitLine: { + show: true, + lineStyle: splitLineStyle, }, }, - elements: { - line: { - tension: 0.4, - cubicInterpolationMode: "monotone", - borderWidth: 1.5, - }, - bar: { borderWidth: 1.5, borderRadius: 4 }, - point: { - hitRadius: 50, - }, + legend: { + show: !this.hideLegend, + icon: "circle", + padding: [20, 0], + data: this._legendData, }, - // @ts-expect-error - locale: numberFormatToLocale(this.hass.locale), - onClick: (e: any) => { - if (!this.clickForMoreInfo || clickIsTouch(e)) { - return; - } - - const chart = e.chart; - - const points = chart.getElementsAtEventForMode( - e, - "nearest", - { intersect: true }, - true - ); - - if (points.length) { - const firstPoint = points[0]; - const statisticId = this._statisticIds[firstPoint.datasetIndex]; - if (!isExternalStatistic(statisticId)) { - fireEvent(this, "hass-more-info", { entityId: statisticId }); - chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip - } - } + grid: { + ...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0 + left: 20, + right: 1, + bottom: 0, + containLabel: true, }, + tooltip: { + trigger: "axis", + appendTo: document.body, + formatter: this._renderTooltip.bind(this), + }, + // scales: { + // x: { + // ticks: { + // source: this.chartType === "bar" ? "data" : undefined, + // }, + // }, + // }, }; } @@ -325,8 +282,8 @@ export class StatisticsChart extends LitElement { let colorIndex = 0; const statisticsData = Object.entries(this.statisticsData); - const totalDataSets: ChartDataset<"line">[] = []; - const totalDatasetExtras: ChartDatasetExtra[] = []; + const totalDataSets: LineSeriesOption[] = []; + const legendData: string[] = []; const statisticIds: string[] = []; let endTime: Date; @@ -372,19 +329,19 @@ export class StatisticsChart extends LitElement { } // array containing [value1, value2, etc] - let prevValues: (number | null)[] | null = null; + let prevValues: (number | null)[][] | null = null; let prevEndTime: Date | undefined; // The datasets for the current statistic - const statDataSets: ChartDataset<"line">[] = []; - const statDatasetExtras: ChartDatasetExtra[] = []; + const statDataSets: (LineSeriesOption | BarSeriesOption)[] = []; + const statLegendData: string[] = []; const pushData = ( start: Date, end: Date, - dataValues: (number | null)[] | null + dataValues: (number | null)[][] ) => { - if (!dataValues) return; + if (!dataValues.length) return; if (start > end) { // Drop data points that are after the requested endTime. This could happen if // endTime is "now" and client time is not in sync with server time. @@ -399,11 +356,10 @@ export class StatisticsChart extends LitElement { ) { // if the end of the previous data doesn't match the start of the current data, // we have to draw a gap so add a value at the end time, and then an empty value. - d.data.push({ x: prevEndTime.getTime(), y: prevValues[i]! }); - // @ts-expect-error - d.data.push({ x: prevEndTime.getTime(), y: null }); + d.data!.push([prevEndTime, ...prevValues[i]!]); + d.data!.push([prevEndTime, null]); } - d.data.push({ x: start.getTime(), y: dataValues[i]! }); + d.data!.push([start, ...dataValues[i]!]); }); prevValues = dataValues; prevEndTime = end; @@ -438,23 +394,25 @@ export class StatisticsChart extends LitElement { }) : this.statTypes; - let displayed_legend = false; + let displayedLegend = false; sortedTypes.forEach((type) => { if (statisticsHaveType(stats, type)) { const band = drawBands && (type === "min" || type === "max"); if (!this.hideLegend) { - const show_legend = hasMean + const showLegend = hasMean ? type === "mean" - : displayed_legend === false; - statDatasetExtras.push({ - legend_label: name, - show_legend, - }); - displayed_legend = displayed_legend || show_legend; + : displayedLegend === false; + if (showLegend) { + statLegendData.push(name); + } + displayedLegend = displayedLegend || showLegend; } statTypes.push(type); - statDataSets.push({ - label: name + const series: LineSeriesOption | BarSeriesOption = { + id: `${statistic_id}-${type}`, + type: this.chartType, + data: [], + name: name ? `${name} (${this.hass.localize( `ui.components.statistics_charts.statistic_types.${type}` )}) @@ -462,25 +420,26 @@ export class StatisticsChart extends LitElement { : this.hass.localize( `ui.components.statistics_charts.statistic_types.${type}` ), - fill: drawBands - ? type === "min" && hasMean - ? "+1" - : type === "max" - ? "-1" - : false - : false, - borderColor: - band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color, - backgroundColor: band ? color + "3F" : color + "7F", - pointRadius: 0, - hidden: !this.hideLegend - ? this._hiddenStats.has(statistic_id) - : false, - data: [], - // @ts-ignore - unit: meta?.unit_of_measurement, - band, - }); + symbol: "circle", + symbolSize: 0, + lineStyle: { + width: 1.5, + }, + color: band && hasMean ? color + "3F" : color, + }; + if (band) { + series.stack = "band"; + (series as LineSeriesOption).symbol = "none"; + (series as LineSeriesOption).lineStyle = { + opacity: 0, + }; + if (drawBands && type === "max") { + (series as LineSeriesOption).areaStyle = { + color: color + "3F", + }; + } + } + statDataSets.push(series); statisticIds.push(statistic_id); } }); @@ -494,37 +453,39 @@ export class StatisticsChart extends LitElement { return; } prevDate = startDate; - const dataValues: (number | null)[] = []; + const dataValues: (number | null)[][] = []; statTypes.forEach((type) => { - let val: number | null | undefined; + const val: (number | null)[] = []; if (type === "sum") { if (firstSum === null || firstSum === undefined) { - val = 0; + val.push(0); firstSum = stat.sum; } else { - val = (stat.sum || 0) - firstSum; + val.push((stat.sum || 0) - firstSum); } + } else if (type === "max") { + const max = stat.max || 0; + val.push(max - (stat.min || 0)); + val.push(max); } else { - val = stat[type]; + val.push(stat[type] ?? null); } - dataValues.push(val ?? null); + dataValues.push(val); }); pushData(startDate, new Date(stat.end), dataValues); }); // Concat two arrays Array.prototype.push.apply(totalDataSets, statDataSets); - Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras); + Array.prototype.push.apply(legendData, statLegendData); }); if (unit) { - this._createOptions(unit); + this.unit = unit; } - this._chartData = { - datasets: totalDataSets, - }; - this._chartDatasetExtra = totalDatasetExtras; + this._chartData = totalDataSets; + this._legendData = legendData; this._statisticIds = statisticIds; } diff --git a/src/components/chart/timeline-chart/const.ts b/src/components/chart/timeline-chart/const.ts deleted file mode 100644 index 81b8bda754..0000000000 --- a/src/components/chart/timeline-chart/const.ts +++ /dev/null @@ -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; - }; - } -} diff --git a/src/components/chart/timeline-chart/textbar-element.ts b/src/components/chart/timeline-chart/textbar-element.ts deleted file mode 100644 index 2750869f56..0000000000 --- a/src/components/chart/timeline-chart/textbar-element.ts +++ /dev/null @@ -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; -} - -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 - ).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 }; - } -} diff --git a/src/components/chart/timeline-chart/timeline-controller.ts b/src/components/chart/timeline-chart/timeline-controller.ts deleted file mode 100644 index 18bfd58876..0000000000 --- a/src/components/chart/timeline-chart/timeline-controller.ts +++ /dev/null @@ -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); - } -} diff --git a/src/components/chart/timeline-chart/timeline-color.ts b/src/components/chart/timeline-color.ts similarity index 77% rename from src/components/chart/timeline-chart/timeline-color.ts rename to src/components/chart/timeline-color.ts index 44cd603047..e6b666ad68 100644 --- a/src/components/chart/timeline-chart/timeline-color.ts +++ b/src/components/chart/timeline-color.ts @@ -1,11 +1,11 @@ import type { HassEntity } from "home-assistant-js-websocket"; -import { getGraphColorByIndex } from "../../../common/color/colors"; -import { hex2rgb, lab2hex, rgb2lab } from "../../../common/color/convert-color"; -import { labBrighten } from "../../../common/color/lab"; -import { computeDomain } from "../../../common/entity/compute_domain"; -import { stateColorProperties } from "../../../common/entity/state_color"; -import { UNAVAILABLE, UNKNOWN } from "../../../data/entity"; -import { computeCssValue } from "../../../resources/css-variables"; +import { getGraphColorByIndex } from "../../common/color/colors"; +import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color"; +import { labBrighten } from "../../common/color/lab"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { stateColorProperties } from "../../common/entity/state_color"; +import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; +import { computeCssValue } from "../../resources/css-variables"; const DOMAIN_STATE_SHADES: Record> = { media_player: { diff --git a/src/panels/config/hardware/ha-config-hardware.ts b/src/panels/config/hardware/ha-config-hardware.ts index ec7119e85e..004bcb0cd1 100644 --- a/src/panels/config/hardware/ha-config-hardware.ts +++ b/src/panels/config/hardware/ha-config-hardware.ts @@ -1,13 +1,13 @@ import "@material/mwc-list/mwc-list"; import "@material/mwc-list/mwc-list-item"; import { mdiPower } from "@mdi/js"; -import type { ChartOptions } from "chart.js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import type { SeriesOption } from "echarts/types/dist/shared"; +import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { numberFormatToLocale } from "../../../common/number/format_number"; import { round } from "../../../common/number/round"; import { blankBeforePercent } from "../../../common/translations/blank_before_percent"; import "../../../components/buttons/ha-progress-button"; @@ -38,16 +38,22 @@ import type { HomeAssistant } from "../../../types"; import { hardwareBrandsUrl } from "../../../util/brands-url"; import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; import { extractApiErrorMessage } from "../../../data/hassio/common"; +import type { ECOption } from "../../../resources/echarts"; +import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label"; const DATASAMPLES = 60; -const DATA_SET_CONFIG = { - fill: "origin", - borderColor: DEFAULT_PRIMARY_COLOR, - backgroundColor: DEFAULT_PRIMARY_COLOR + "2B", - pointRadius: 0, - lineTension: 0.2, - borderWidth: 1, +const DATA_SET_CONFIG: SeriesOption = { + type: "line", + color: DEFAULT_PRIMARY_COLOR, + areaStyle: { + color: DEFAULT_PRIMARY_COLOR + "2B", + }, + symbolSize: 0, + lineStyle: { + width: 1, + }, + smooth: 0.25, }; @customElement("ha-config-hardware") @@ -62,15 +68,15 @@ class HaConfigHardware extends SubscribeMixin(LitElement) { @state() private _hardwareInfo?: HardwareInfo; - @state() private _chartOptions?: ChartOptions; + @state() private _chartOptions?: ECOption; @state() private _systemStatusData?: SystemStatusStreamMessage; @state() private _configEntries?: Record; - 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)[] { const subs = [ @@ -121,14 +127,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) { this._memoryEntries.shift(); this._cpuEntries.shift(); - this._memoryEntries.push({ - x: new Date(message.timestamp).getTime(), - y: message.memory_used_percent, - }); - this._cpuEntries.push({ - x: new Date(message.timestamp).getTime(), - y: message.cpu_percent, - }); + this._memoryEntries.push([ + new Date(message.timestamp).getTime(), + message.memory_used_percent, + ]); + this._cpuEntries.push([ + new Date(message.timestamp).getTime(), + message.cpu_percent, + ]); this._systemStatusData = message; }, @@ -143,51 +149,44 @@ class HaConfigHardware extends SubscribeMixin(LitElement) { } protected willUpdate(): void { - if (!this.hasUpdated) { + if (!this.hasUpdated && !this._chartOptions) { this._chartOptions = { - responsive: true, - scales: { - y: { - gridLines: { - drawTicks: false, - }, - ticks: { - maxTicksLimit: 7, - fontSize: 10, - max: 100, - min: 0, - stepSize: 1, - callback: (value) => - value + blankBeforePercent(this.hass.locale) + "%", - }, + xAxis: { + type: "time", + axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config), + splitLine: { + show: true, }, - x: { - type: "time", - adapters: { - date: { - locale: this.hass.locale, - config: this.hass.config, - }, - }, - gridLines: { - display: true, - drawTicks: false, - }, - ticks: { - maxRotation: 0, - sampleSize: 5, - autoSkipPadding: 20, - major: { - enabled: true, - }, - fontSize: 10, - autoSkip: true, - maxTicksLimit: 5, - }, + axisLine: { + show: false, }, }, - // @ts-expect-error - locale: numberFormatToLocale(this.hass.locale), + yAxis: { + type: "value", + splitLine: { + show: true, + }, + axisLabel: { + formatter: (value: number) => + value + blankBeforePercent(this.hass.locale) + "%", + }, + axisLine: { + show: false, + }, + scale: true, + }, + grid: { + top: 10, + bottom: 10, + left: 10, + right: 10, + containLabel: true, + }, + tooltip: { + trigger: "axis", + valueFormatter: (value) => + value + blankBeforePercent(this.hass.locale) + "%", + }, }; } } @@ -201,8 +200,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) { for (let i = 0; i < DATASAMPLES; i++) { const t = new Date(date); t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i)); - this._memoryEntries.push({ x: t.getTime(), y: null }); - this._cpuEntries.push({ x: t.getTime(), y: null }); + this._memoryEntries.push([t.getTime(), null]); + this._cpuEntries.push([t.getTime(), null]); } } @@ -387,14 +386,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
@@ -419,14 +411,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
@@ -482,6 +467,20 @@ class HaConfigHardware extends SubscribeMixin(LitElement) { showRestartDialog(this); } + private _getChartData = memoizeOne( + (entries: [number, number | null][]): SeriesOption[] => [ + { + ...DATA_SET_CONFIG, + id: entries === this._cpuEntries ? "cpu" : "memory", + name: + entries === this._cpuEntries + ? this.hass.localize("ui.panel.config.hardware.processor") + : this.hass.localize("ui.panel.config.hardware.memory"), + data: entries, + } as SeriesOption, + ] + ); + static styles = [ haStyle, css` diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index bc8ce0edd9..3f7dd86d7a 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -196,6 +196,7 @@ class HaPanelHistory extends LitElement { .historyData=${this._mungedStateHistory} .startTime=${this._startDate} .endTime=${this._endDate} + .narrow=${this.narrow} > `} diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index 0582638626..bf13990400 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -12,7 +12,7 @@ import { restoreScroll } from "../../common/decorators/restore-scroll"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; import { navigate } from "../../common/navigate"; -import { computeTimelineColor } from "../../components/chart/timeline-chart/timeline-color"; +import { computeTimelineColor } from "../../components/chart/timeline-color"; import "../../components/entity/state-badge"; import "../../components/ha-circular-progress"; import "../../components/ha-icon-next"; diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 3e1328501c..3619c731d3 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -1,18 +1,16 @@ -import type { ChartOptions } from "chart.js"; import type { HassConfig } from "home-assistant-js-websocket"; -import { - addHours, - subHours, - differenceInDays, - differenceInHours, -} from "date-fns"; +import { addHours, subHours, differenceInDays } from "date-fns"; +import type { + BarSeriesOption, + CallbackDataParams, + TopLevelFormatterParams, +} from "echarts/types/dist/shared"; import type { FrontendLocaleData } from "../../../../../data/translation"; -import { - formatNumber, - numberFormatToLocale, -} from "../../../../../common/number/format_number"; +import { formatNumber } from "../../../../../common/number/format_number"; import { formatDateVeryShort } from "../../../../../common/datetime/format_date"; import { formatTime } from "../../../../../common/datetime/format_time"; +import type { ECOption } from "../../../../../resources/echarts"; +import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label"; export function getSuggestedMax(dayDifference: number, end: Date): number { let suggestedMax = new Date(end); @@ -46,127 +44,216 @@ export function getCommonOptions( config: HassConfig, unit?: string, compareStart?: Date, - compareEnd?: Date -): ChartOptions { + compareEnd?: Date, + formatTotal?: (total: number) => string +): ECOption { const dayDifference = differenceInDays(end, start); const compare = compareStart !== undefined && compareEnd !== undefined; - if (compare && dayDifference <= 35) { - const difference = differenceInHours(end, start); - const differenceCompare = differenceInHours(compareEnd!, compareStart!); - // If the compare period doesn't match the main period, adjust them to match - if (differenceCompare > difference) { - end = addHours(end, differenceCompare - difference); - } else if (difference > differenceCompare) { - compareEnd = addHours(compareEnd!, difference - differenceCompare); - } - } - const options: ChartOptions = { - parsing: false, - interaction: { - mode: "x", - }, - scales: { - x: { - type: "time", - suggestedMin: start.getTime(), - max: getSuggestedMax(dayDifference, end), - adapters: { - date: { - locale, - config, - }, - }, - ticks: { - maxRotation: 0, - sampleSize: 5, - autoSkipPadding: 20, - font: (context) => - context.tick && context.tick.major - ? ({ weight: "bold" } as any) - : {}, - }, - time: { - tooltipFormat: - dayDifference > 35 - ? "monthyear" - : dayDifference > 7 - ? "date" - : dayDifference > 2 - ? "weekday" - : dayDifference > 0 - ? "datetime" - : "hour", - minUnit: getSuggestedPeriod(dayDifference), - }, + const options: ECOption = { + xAxis: { + id: "xAxisMain", + type: "time", + min: start.getTime(), + max: getSuggestedMax(dayDifference, end), + axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference), + axisLine: { + show: false, }, - y: { - stacked: true, - type: "linear", - title: { - display: true, - text: unit, - }, - ticks: { - beginAtZero: true, - callback: (value) => formatNumber(Math.abs(value), locale), - }, + splitLine: { + show: true, + }, + minInterval: + dayDifference >= 89 // quarter + ? 28 * 3600 * 24 * 1000 + : dayDifference > 2 + ? 3600 * 24 * 1000 + : undefined, + }, + yAxis: { + type: "value", + name: unit, + nameGap: 5, + axisLabel: { + formatter: (value: number) => formatNumber(Math.abs(value), locale), + }, + splitLine: { + show: true, }, }, - plugins: { - tooltip: { - position: "nearest", - filter: (val) => val.formattedValue !== "0", - itemSort: function (a, b) { - return b.datasetIndex - a.datasetIndex; - }, - callbacks: { - title: (datasets) => { - if (dayDifference > 0) { - return datasets[0].label; + grid: { + top: 35, + bottom: 10, + left: 10, + right: 10, + containLabel: true, + }, + tooltip: { + trigger: "axis", + formatter: (params: TopLevelFormatterParams): string => { + // trigger: "axis" gives an array of params, but "item" gives a single param + if (Array.isArray(params)) { + const mainItems: CallbackDataParams[] = []; + const compareItems: CallbackDataParams[] = []; + params.forEach((param: CallbackDataParams) => { + if (param.seriesId?.startsWith("compare-")) { + compareItems.push(param); + } else { + mainItems.push(param); } - const date = new Date(datasets[0].parsed.x); - return `${ - compare ? `${formatDateVeryShort(date, locale, config)}: ` : "" - }${formatTime(date, locale, config)} – ${formatTime( - addHours(date, 1), - locale, - config - )}`; - }, - label: (context) => - `${context.dataset.label}: ${formatNumber( - context.parsed.y, - locale - )} ${unit}`, - }, - }, - filler: { - propagate: false, - }, - legend: { - display: false, - labels: { - usePointStyle: true, - }, + }); + return [mainItems, compareItems] + .filter((items) => items.length > 0) + .map((items) => + formatTooltip( + items, + locale, + config, + dayDifference, + compare, + unit, + formatTotal + ) + ) + .join("

"); + } + return formatTooltip( + [params], + locale, + config, + dayDifference, + compare, + unit, + formatTotal + ); }, }, - elements: { - bar: { borderWidth: 1.5, borderRadius: 4 }, - point: { - hitRadius: 50, - }, - }, - // @ts-expect-error - locale: numberFormatToLocale(locale), }; - if (compare) { - options.scales!.xAxisCompare = { - ...(options.scales!.x as Record), - suggestedMin: compareStart!.getTime(), - max: getSuggestedMax(dayDifference, compareEnd!), - display: false, - }; - } return options; } + +function formatTooltip( + params: CallbackDataParams[], + locale: FrontendLocaleData, + config: HassConfig, + dayDifference: number, + compare: boolean | null, + unit?: string, + formatTotal?: (total: number) => string +) { + if (!params[0].value) { + return ""; + } + // when comparing the first value is offset to match the main period + // and the real date is in the third value + const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]); + let period: string; + if (dayDifference > 0) { + period = `${formatDateVeryShort(date, locale, config)}`; + } else { + period = `${ + compare ? `${formatDateVeryShort(date, locale, config)}: ` : "" + }${formatTime(date, locale, config)} – ${formatTime( + addHours(date, 1), + locale, + config + )}`; + } + const title = `

${period}

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

${this._getDeviceName( + params.value[1] + )}

`; + const value = `${formatNumber( + params.value[0] as number, + this.hass.locale, + getNumberFormatOptions(undefined, this.hass.entities[params.value[1]]) + )} kWh`; + return `${title}${params.marker} ${params.seriesName}: ${value}`; + } - const index = Math.abs( - chart.scales.y.getValueForPixel(canvasPosition.y) - ); - // @ts-ignore - const statisticId = this._chartData?.datasets[0]?.data[index]?.y; - if (!statisticId || isExternalStatistic(statisticId)) return; - fireEvent(this, "hass-more-info", { - entityId: statisticId, - }); - chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip + private _createOptions = memoizeOne( + (darkMode: boolean): ECOption => ({ + xAxis: { + type: "value", + name: "kWh", + splitLine: { + lineStyle: darkMode ? { opacity: 0.15 } : {}, + }, + }, + yAxis: { + type: "category", + inverse: true, + axisLabel: { + formatter: this._getDeviceName.bind(this), + overflow: "truncate", + }, + }, + grid: { + top: 5, + left: 5, + right: 40, + bottom: 0, + containLabel: true, + }, + tooltip: { + show: true, + formatter: this._renderTooltip.bind(this), }, }) ); @@ -189,51 +153,53 @@ export class HuiEnergyDevicesGraphCard const data = energyData.stats; const compareData = energyData.statsCompare; - const chartData: ChartDataset<"bar", ParsedDataType<"bar">>["data"][] = []; - const chartDataCompare: ChartDataset< - "bar", - ParsedDataType<"bar"> - >["data"][] = []; - const borderColor: string[] = []; - const borderColorCompare: string[] = []; - const backgroundColor: string[] = []; - const backgroundColorCompare: string[] = []; + const chartData: NonNullable = []; + const chartDataCompare: NonNullable = []; - const datasets: ChartDataset<"bar", ParsedDataType<"bar">[]>[] = [ + const datasets: BarSeriesOption[] = [ { - label: this.hass.localize( + type: "bar", + name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_devices_graph.energy_usage" ), - borderColor, - backgroundColor, + itemStyle: { + borderRadius: [0, 4, 4, 0], + }, data: chartData, - barThickness: compareData ? 10 : 20, + barWidth: compareData ? 10 : 20, }, ]; if (compareData) { datasets.push({ - label: this.hass.localize( + type: "bar", + name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_devices_graph.previous_energy_usage" ), - borderColor: borderColorCompare, - backgroundColor: backgroundColorCompare, + itemStyle: { + borderRadius: [0, 4, 4, 0], + }, data: chartDataCompare, - barThickness: 10, + barWidth: 10, }); } - energyData.prefs.device_consumption.forEach((device, idx) => { + const computedStyle = getComputedStyle(this); + + energyData.prefs.device_consumption.forEach((device, id) => { const value = device.stat_consumption in data ? calculateStatisticSumGrowth(data[device.stat_consumption]) || 0 : 0; + const color = getGraphColorByIndex(id, computedStyle); chartData.push({ - // @ts-expect-error - y: device.stat_consumption, - x: value, - idx, + id, + value: [value, device.stat_consumption], + itemStyle: { + color: color + "7F", + borderColor: color, + }, }); if (compareData) { @@ -245,40 +211,22 @@ export class HuiEnergyDevicesGraphCard : 0; chartDataCompare.push({ - // @ts-expect-error - y: device.stat_consumption, - x: compareValue, - idx, + id, + value: [compareValue, device.stat_consumption], + itemStyle: { + color: color + "32", + borderColor: color + "7F", + }, }); } }); - chartData.sort((a, b) => b.x - a.x); + chartData.sort((a: any, b: any) => b.value[0] - a.value[0]); chartData.length = this._config?.max_devices || chartData.length; - const computedStyle = getComputedStyle(this); - - chartData.forEach((d: any) => { - const color = getGraphColorByIndex(d.idx, computedStyle); - - borderColor.push(color); - backgroundColor.push(color + "7F"); - }); - - chartDataCompare.forEach((d: any) => { - const color = getGraphColorByIndex(d.idx, computedStyle); - - borderColorCompare.push(color + "7F"); - backgroundColorCompare.push(color + "32"); - }); - - this._chartData = { - labels: chartData.map((d) => d.y), - datasets, - }; + this._chartData = datasets; await this.updateComplete; - this._chart?.updateChart("none"); } static styles = css` diff --git a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts index 30cebd3d12..5774f16ec1 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts @@ -1,9 +1,3 @@ -import type { - ChartData, - ChartDataset, - ChartOptions, - ScatterDataPoint, -} from "chart.js"; import { endOfToday, isToday, startOfToday } from "date-fns"; import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; @@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import type { BarSeriesOption } from "echarts/charts"; import { getEnergyColor } from "./common/color"; import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/chart/ha-chart-base"; @@ -31,7 +26,11 @@ import type { HomeAssistant } from "../../../../types"; import type { LovelaceCard } from "../../types"; import type { EnergyGasGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; -import { getCommonOptions } from "./common/energy-chart-options"; +import { + fillDataGapsAndRoundCaps, + getCommonOptions, +} from "./common/energy-chart-options"; +import type { ECOption } from "../../../../resources/echarts"; @customElement("hui-energy-gas-graph-card") export class HuiEnergyGasGraphCard @@ -42,9 +41,7 @@ export class HuiEnergyGasGraphCard @state() private _config?: EnergyGasGraphCardConfig; - @state() private _chartData: ChartData = { - datasets: [], - }; + @state() private _chartData: BarSeriesOption[] = []; @state() private _start = startOfToday(); @@ -111,7 +108,7 @@ export class HuiEnergyGasGraphCard )} chart-type="bar" > - ${!this._chartData.datasets.length + ${!this._chartData.length ? html`
${isToday(this._start) ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") @@ -125,6 +122,12 @@ export class HuiEnergyGasGraphCard `; } + private _formatTotal = (total: number) => + this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed", + { num: formatNumber(total, this.hass.locale), unit: this._unit } + ); + private _createOptions = memoizeOne( ( start: Date, @@ -134,51 +137,26 @@ export class HuiEnergyGasGraphCard unit?: string, compareStart?: Date, compareEnd?: Date - ): ChartOptions => { - const commonOptions = getCommonOptions( + ): ECOption => + getCommonOptions( start, end, locale, config, unit, compareStart, - compareEnd - ); - const options: ChartOptions = { - ...commonOptions, - plugins: { - ...commonOptions.plugins, - tooltip: { - ...commonOptions.plugins!.tooltip, - callbacks: { - ...commonOptions.plugins!.tooltip!.callbacks, - footer: (contexts) => { - if (contexts.length < 2) { - return []; - } - let total = 0; - for (const context of contexts) { - total += (context.dataset.data[context.dataIndex] as any).y; - } - if (total === 0) { - return []; - } - return [ - this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed", - { num: formatNumber(total, locale), unit } - ), - ]; - }, - }, - }, - }, - }; - return options; - } + compareEnd, + this._formatTotal + ) ); private async _getStatistics(energyData: EnergyData): Promise { + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + const gasSources: GasSourceTypeEnergyPreference[] = energyData.prefs.energy_sources.filter( (source) => source.type === "gas" @@ -188,10 +166,32 @@ export class HuiEnergyGasGraphCard getEnergyGasUnit(this.hass, energyData.prefs, energyData.statsMetadata) || "m³"; - const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const datasets: BarSeriesOption[] = []; const computedStyles = getComputedStyle(this); + if (energyData.statsCompare) { + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + energyData.statsMetadata, + gasSources, + computedStyles, + true + ) + ); + } else { + // add empty dataset so compare bars are first + // `stack: gas` so it doesn't take up space yet + const firstId = gasSources[0]?.stat_energy_from ?? "placeholder"; + datasets.push({ + id: "compare-" + firstId, + type: "bar", + stack: "gas", + data: [], + }); + } + datasets.push( ...this._processDataSet( energyData.stats, @@ -201,38 +201,8 @@ export class HuiEnergyGasGraphCard ) ); - if (energyData.statsCompare) { - // Add empty dataset to align the bars - datasets.push({ - order: 0, - data: [], - }); - datasets.push({ - order: 999, - data: [], - xAxisID: "xAxisCompare", - }); - - datasets.push( - ...this._processDataSet( - energyData.statsCompare, - energyData.statsMetadata, - gasSources, - computedStyles, - true - ) - ); - } - - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._compareStart = energyData.startCompare; - this._compareEnd = energyData.endCompare; - - this._chartData = { - datasets, - }; + fillDataGapsAndRoundCaps(datasets); + this._chartData = datasets; } private _processDataSet( @@ -242,56 +212,62 @@ export class HuiEnergyGasGraphCard computedStyles: CSSStyleDeclaration, compare = false ) { - const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const data: BarSeriesOption[] = []; + const compareOffset = compare + ? this._start.getTime() - this._compareStart!.getTime() + : 0; gasSources.forEach((source, idx) => { let prevStart: number | null = null; - const gasConsumptionData: ScatterDataPoint[] = []; + const gasConsumptionData: BarSeriesOption["data"] = []; // Process gas consumption data. if (source.stat_energy_from in statistics) { const stats = statistics[source.stat_energy_from]; - let end; - for (const point of stats) { - if (point.change === null || point.change === undefined) { + if ( + point.change === null || + point.change === undefined || + point.change === 0 + ) { continue; } if (prevStart === point.start) { continue; } - const date = new Date(point.start); - gasConsumptionData.push({ - x: date.getTime(), - y: point.change, - }); + const dataPoint = [point.start, point.change]; + if (compare) { + dataPoint[2] = dataPoint[0]; + dataPoint[0] += compareOffset; + } + gasConsumptionData.push(dataPoint); prevStart = point.start; - end = point.end; - } - if (gasConsumptionData.length === 1) { - gasConsumptionData.push({ - x: end, - y: 0, - }); } } data.push({ - label: getStatisticLabel( + type: "bar", + id: compare + ? "compare-" + source.stat_energy_from + : source.stat_energy_from, + name: getStatisticLabel( this.hass, source.stat_energy_from, statisticsMetaData[source.stat_energy_from] ), - borderColor: getEnergyColor( - computedStyles, - this.hass.themes.darkMode, - false, - compare, - "--energy-gas-color", - idx - ), - backgroundColor: getEnergyColor( + barMaxWidth: 50, + itemStyle: { + borderColor: getEnergyColor( + computedStyles, + this.hass.themes.darkMode, + false, + compare, + "--energy-gas-color", + idx + ), + }, + color: getEnergyColor( computedStyles, this.hass.themes.darkMode, true, @@ -300,9 +276,7 @@ export class HuiEnergyGasGraphCard idx ), data: gasConsumptionData, - order: 1, - stack: "gas", - xAxisID: compare ? "xAxisCompare" : undefined, + stack: compare ? "compare-gas" : "gas", }); }); return data; diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index d8b4699e94..13a37565da 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -1,9 +1,3 @@ -import type { - ChartData, - ChartDataset, - ChartOptions, - ScatterDataPoint, -} from "chart.js"; import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns"; import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; @@ -11,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import type { BarSeriesOption, LineSeriesOption } from "echarts/charts"; import { getEnergyColor } from "./common/color"; import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/chart/ha-chart-base"; @@ -32,7 +27,11 @@ import type { HomeAssistant } from "../../../../types"; import type { LovelaceCard } from "../../types"; import type { EnergySolarGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; -import { getCommonOptions } from "./common/energy-chart-options"; +import { + fillDataGapsAndRoundCaps, + getCommonOptions, +} from "./common/energy-chart-options"; +import type { ECOption } from "../../../../resources/echarts"; @customElement("hui-energy-solar-graph-card") export class HuiEnergySolarGraphCard @@ -43,9 +42,7 @@ export class HuiEnergySolarGraphCard @state() private _config?: EnergySolarGraphCardConfig; - @state() private _chartData: ChartData = { - datasets: [], - }; + @state() private _chartData: ECOption["series"][] = []; @state() private _start = startOfToday(); @@ -109,7 +106,7 @@ export class HuiEnergySolarGraphCard )} chart-type="bar" > - ${!this._chartData.datasets.length + ${!this._chartData.length ? html`
${isToday(this._start) ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") @@ -123,6 +120,12 @@ export class HuiEnergySolarGraphCard `; } + private _formatTotal = (total: number) => + this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_solar_graph.total_produced", + { num: formatNumber(total, this.hass.locale) } + ); + private _createOptions = memoizeOne( ( start: Date, @@ -131,64 +134,26 @@ export class HuiEnergySolarGraphCard config: HassConfig, compareStart?: Date, compareEnd?: Date - ): ChartOptions => { - const commonOptions = getCommonOptions( + ): ECOption => + getCommonOptions( start, end, locale, config, "kWh", compareStart, - compareEnd - ); - const options: ChartOptions = { - ...commonOptions, - plugins: { - ...commonOptions.plugins, - tooltip: { - ...commonOptions.plugins!.tooltip, - callbacks: { - ...commonOptions.plugins!.tooltip!.callbacks, - footer: (contexts) => { - const production_contexts = contexts.filter( - (c) => c.dataset?.stack === "solar" - ); - if (production_contexts.length < 2) { - return []; - } - let total = 0; - for (const context of production_contexts) { - total += (context.dataset.data[context.dataIndex] as any).y; - } - if (total === 0) { - return []; - } - return [ - this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_solar_graph.total_produced", - { num: formatNumber(total, locale) } - ), - ]; - }, - }, - }, - }, - elements: { - line: { - tension: 0.3, - borderWidth: 1.5, - }, - bar: { borderWidth: 1.5, borderRadius: 4 }, - point: { - hitRadius: 5, - }, - }, - }; - return options; - } + compareEnd, + this._formatTotal + ) ); private async _getStatistics(energyData: EnergyData): Promise { + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + const solarSources: SolarSourceTypeEnergyPreference[] = energyData.prefs.energy_sources.filter( (source) => source.type === "solar" @@ -205,10 +170,32 @@ export class HuiEnergySolarGraphCard } } - const datasets: ChartDataset<"bar" | "line">[] = []; + const datasets: ECOption["series"] = []; const computedStyles = getComputedStyle(this); + if (energyData.statsCompare) { + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + energyData.statsMetadata, + solarSources, + computedStyles, + true + ) + ); + } else { + // add empty dataset so compare bars are first + // `stack: solar` so it doesn't take up space yet + const firstId = solarSources[0]?.stat_energy_from ?? "placeholder"; + datasets.push({ + id: "compare-" + firstId, + type: "bar", + stack: "solar", + data: [], + }); + } + datasets.push( ...this._processDataSet( energyData.stats, @@ -218,28 +205,7 @@ export class HuiEnergySolarGraphCard ) ); - if (energyData.statsCompare) { - // Add empty dataset to align the bars - datasets.push({ - order: 0, - data: [], - }); - datasets.push({ - order: 999, - data: [], - xAxisID: "xAxisCompare", - }); - - datasets.push( - ...this._processDataSet( - energyData.statsCompare, - energyData.statsMetadata, - solarSources, - computedStyles, - true - ) - ); - } + fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]); if (forecasts) { datasets.push( @@ -254,15 +220,7 @@ export class HuiEnergySolarGraphCard ); } - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._compareStart = energyData.startCompare; - this._compareEnd = energyData.endCompare; - - this._chartData = { - datasets, - }; + this._chartData = datasets; } private _processDataSet( @@ -272,43 +230,47 @@ export class HuiEnergySolarGraphCard computedStyles: CSSStyleDeclaration, compare = false ) { - const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const data: BarSeriesOption[] = []; + const compareOffset = compare + ? this._start.getTime() - this._compareStart!.getTime() + : 0; solarSources.forEach((source, idx) => { let prevStart: number | null = null; - const solarProductionData: ScatterDataPoint[] = []; + const solarProductionData: BarSeriesOption["data"] = []; // Process solar production data. if (source.stat_energy_from in statistics) { const stats = statistics[source.stat_energy_from]; - let end; for (const point of stats) { - if (point.change === null || point.change === undefined) { + if ( + point.change === null || + point.change === undefined || + point.change === 0 + ) { continue; } if (prevStart === point.start) { continue; } - const date = new Date(point.start); - solarProductionData.push({ - x: date.getTime(), - y: point.change, - }); + const dataPoint = [point.start, point.change]; + if (compare) { + dataPoint[2] = dataPoint[0]; + dataPoint[0] += compareOffset; + } + solarProductionData.push(dataPoint); prevStart = point.start; - end = point.end; - } - if (solarProductionData.length === 1) { - solarProductionData.push({ - x: end, - y: 0, - }); } } data.push({ - label: this.hass.localize( + type: "bar", + id: compare + ? "compare-" + source.stat_energy_from + : source.stat_energy_from, + name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_solar_graph.production", { name: getStatisticLabel( @@ -318,15 +280,18 @@ export class HuiEnergySolarGraphCard ), } ), - borderColor: getEnergyColor( - computedStyles, - this.hass.themes.darkMode, - false, - compare, - "--energy-solar-color", - idx - ), - backgroundColor: getEnergyColor( + barMaxWidth: 50, + itemStyle: { + borderColor: getEnergyColor( + computedStyles, + this.hass.themes.darkMode, + false, + compare, + "--energy-solar-color", + idx + ), + }, + color: getEnergyColor( computedStyles, this.hass.themes.darkMode, true, @@ -335,9 +300,7 @@ export class HuiEnergySolarGraphCard idx ), data: solarProductionData, - order: 1, - stack: "solar", - xAxisID: compare ? "xAxisCompare" : undefined, + stack: compare ? "compare" : "solar", }); }); @@ -352,7 +315,7 @@ export class HuiEnergySolarGraphCard start: Date, end?: Date ) { - const data: ChartDataset<"line">[] = []; + const data: LineSeriesOption[] = []; const dayDifference = differenceInDays(end || new Date(), start); @@ -389,18 +352,16 @@ export class HuiEnergySolarGraphCard }); if (forecastsData) { - const solarForecastData: ScatterDataPoint[] = []; + const solarForecastData: LineSeriesOption["data"] = []; for (const [time, value] of Object.entries(forecastsData)) { - solarForecastData.push({ - x: Number(time), - y: value / 1000, - }); + solarForecastData.push([Number(time), value / 1000]); } if (solarForecastData.length) { data.push({ + id: "forecast-" + source.stat_energy_from, type: "line", - label: this.hass.localize( + name: this.hass.localize( "ui.panel.lovelace.cards.energy.energy_solar_graph.forecast", { name: getStatisticLabel( @@ -410,11 +371,13 @@ export class HuiEnergySolarGraphCard ), } ), - fill: false, - stepped: false, - borderColor, - borderDash: [7, 5], - pointRadius: 0, + step: false, + color: borderColor, + lineStyle: { + type: [7, 5], + width: 1.5, + }, + symbol: "none", data: solarForecastData, }); } diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 6dba19c24c..ede8804f64 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -1,9 +1,3 @@ -import type { - ChartData, - ChartDataset, - ChartOptions, - ScatterDataPoint, -} from "chart.js"; import { endOfToday, isToday, startOfToday } from "date-fns"; import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; @@ -11,6 +5,11 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import type { BarSeriesOption } from "echarts/charts"; +import type { + TooltipOption, + TopLevelFormatterParams, +} from "echarts/types/dist/shared"; import { getEnergyColor } from "./common/color"; import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/chart/ha-chart-base"; @@ -25,7 +24,11 @@ import type { HomeAssistant } from "../../../../types"; import type { LovelaceCard } from "../../types"; import type { EnergyUsageGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; -import { getCommonOptions } from "./common/energy-chart-options"; +import { + fillDataGapsAndRoundCaps, + getCommonOptions, +} from "./common/energy-chart-options"; +import type { ECOption } from "../../../../resources/echarts"; const colorPropertyMap = { to_grid: "--energy-grid-return-color", @@ -45,9 +48,7 @@ export class HuiEnergyUsageGraphCard @state() private _config?: EnergyUsageGraphCardConfig; - @state() private _chartData: ChartData = { - datasets: [], - }; + @state() private _chartData: BarSeriesOption[] = []; @state() private _start = startOfToday(); @@ -111,7 +112,7 @@ export class HuiEnergyUsageGraphCard )} chart-type="bar" > - ${!this._chartData.datasets.some((dataset) => dataset.data.length) + ${!this._chartData.some((dataset) => dataset.data!.length) ? html`
${isToday(this._start) ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") @@ -125,6 +126,17 @@ export class HuiEnergyUsageGraphCard `; } + private _formatTotal = (total: number) => + total > 0 + ? this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed", + { num: formatNumber(total, this.hass.locale) } + ) + : this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned", + { num: formatNumber(-total, this.hass.locale) } + ); + private _createOptions = memoizeOne( ( start: Date, @@ -133,7 +145,7 @@ export class HuiEnergyUsageGraphCard config: HassConfig, compareStart?: Date, compareEnd?: Date - ): ChartOptions => { + ): ECOption => { const commonOptions = getCommonOptions( start, end, @@ -141,56 +153,34 @@ export class HuiEnergyUsageGraphCard config, "kWh", compareStart, - compareEnd + compareEnd, + this._formatTotal ); - const options: ChartOptions = { + const options: ECOption = { ...commonOptions, - plugins: { - ...commonOptions.plugins, - tooltip: { - ...commonOptions.plugins!.tooltip, - itemSort: function (a: any, b: any) { - if (a.raw?.y > 0 && b.raw?.y < 0) { + tooltip: { + ...commonOptions.tooltip, + formatter: (params: TopLevelFormatterParams): string => { + if (!Array.isArray(params)) { + return ""; + } + params.sort((a, b) => { + const aValue = (a.value as number[])?.[1]; + const bValue = (b.value as number[])?.[1]; + if (aValue > 0 && bValue < 0) { return -1; } - if (b.raw?.y > 0 && a.raw?.y < 0) { + if (bValue > 0 && aValue < 0) { return 1; } - if (a.raw?.y > 0) { - return b.datasetIndex - a.datasetIndex; + if (aValue > 0) { + return b.componentIndex - a.componentIndex; } - return a.datasetIndex - b.datasetIndex; - }, - callbacks: { - ...commonOptions.plugins!.tooltip!.callbacks, - footer: (contexts) => { - let totalConsumed = 0; - let totalReturned = 0; - for (const context of contexts) { - const value = (context.dataset.data[context.dataIndex] as any) - .y; - if (value > 0) { - totalConsumed += value; - } else { - totalReturned += Math.abs(value); - } - } - return [ - totalConsumed - ? this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed", - { num: formatNumber(totalConsumed, locale) } - ) - : "", - totalReturned - ? this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned", - { num: formatNumber(totalReturned, locale) } - ) - : "", - ].filter(Boolean); - }, - }, + return a.componentIndex - b.componentIndex; + }); + return ( + (commonOptions.tooltip as TooltipOption)?.formatter as any + )?.(params); }, }, }; @@ -199,7 +189,7 @@ export class HuiEnergyUsageGraphCard ); private async _getStatistics(energyData: EnergyData): Promise { - const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const datasets: BarSeriesOption[] = []; const statIds: { to_grid?: string[]; @@ -288,6 +278,30 @@ export class HuiEnergyUsageGraphCard this._compareStart = energyData.startCompare; this._compareEnd = energyData.endCompare; + if (energyData.statsCompare) { + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + energyData.statsMetadata, + statIds, + colorIndices, + computedStyles, + labels, + true + ) + ); + } else { + // add empty dataset so compare bars are first + // `stack: usage` so it doesn't take up space yet + const firstId = statIds.from_grid?.[0] ?? "placeholder"; + datasets.push({ + id: "compare-" + firstId, + type: "bar", + stack: "usage", + data: [], + }); + } + datasets.push( ...this._processDataSet( energyData.stats, @@ -300,34 +314,8 @@ export class HuiEnergyUsageGraphCard ) ); - if (energyData.statsCompare) { - // Add empty dataset to align the bars - datasets.push({ - order: 0, - data: [], - }); - datasets.push({ - order: 999, - data: [], - xAxisID: "xAxisCompare", - }); - - datasets.push( - ...this._processDataSet( - energyData.statsCompare, - energyData.statsMetadata, - statIds, - colorIndices, - computedStyles, - labels, - true - ) - ); - } - - this._chartData = { - datasets, - }; + fillDataGapsAndRoundCaps(datasets); + this._chartData = datasets; } private _processDataSet( @@ -349,7 +337,7 @@ export class HuiEnergyUsageGraphCard }, compare = false ) { - const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const data: BarSeriesOption[] = []; const combinedData: { to_grid?: Record>; @@ -368,7 +356,6 @@ export class HuiEnergyUsageGraphCard solar?: Record; } = {}; - let pointEndTime; Object.entries(statIdsByCat).forEach(([key, statIds]) => { const sum = [ "solar", @@ -396,11 +383,9 @@ export class HuiEnergyUsageGraphCard if (sum) { totalStats[stat.start] = stat.start in totalStats ? totalStats[stat.start] + val : val; - pointEndTime = stat.end; } if (add && !(stat.start in set)) { set[stat.start] = val; - pointEndTime = stat.end; } }); sets[id] = set; @@ -491,29 +476,33 @@ export class HuiEnergyUsageGraphCard (a, b) => Number(a) - Number(b) ); + const compareOffset = compare + ? this._start.getTime() - this._compareStart!.getTime() + : 0; + Object.entries(combinedData).forEach(([type, sources]) => { - Object.entries(sources).forEach(([statId, source], idx) => { - const points: ScatterDataPoint[] = []; + Object.entries(sources).forEach(([statId, source]) => { + const points: BarSeriesOption["data"] = []; // Process chart data. for (const key of uniqueKeys) { const value = source[key] || 0; - points.push({ - x: Number(key), - y: - value && ["to_grid", "to_battery"].includes(type) - ? -1 * value - : value, - }); - } - if (points.length === 1) { - points.push({ - x: pointEndTime, - y: 0, - }); + const dataPoint = [ + Number(key), + value && ["to_grid", "to_battery"].includes(type) + ? -1 * value + : value, + ]; + if (compare) { + dataPoint[2] = dataPoint[0]; + dataPoint[0] += compareOffset; + } + points.push(dataPoint); } data.push({ - label: + id: compare ? "compare-" + statId : statId, + type: "bar", + name: type in labels ? labels[type] : getStatisticLabel( @@ -521,21 +510,18 @@ export class HuiEnergyUsageGraphCard statId, statisticsMetaData[statId] ), - order: - type === "used_solar" - ? 1 - : type === "to_battery" - ? Object.keys(combinedData).length - : idx + 2, - borderColor: getEnergyColor( - computedStyles, - this.hass.themes.darkMode, - false, - compare, - colorPropertyMap[type], - colorIndices[type]?.[statId] - ), - backgroundColor: getEnergyColor( + barMaxWidth: 50, + itemStyle: { + borderColor: getEnergyColor( + computedStyles, + this.hass.themes.darkMode, + false, + compare, + colorPropertyMap[type], + colorIndices[type]?.[statId] + ), + }, + color: getEnergyColor( computedStyles, this.hass.themes.darkMode, true, @@ -543,9 +529,8 @@ export class HuiEnergyUsageGraphCard colorPropertyMap[type], colorIndices[type]?.[statId] ), - stack: "stack", + stack: compare ? "compare" : "usage", data: points, - xAxisID: compare ? "xAxisCompare" : undefined, }); }); }); diff --git a/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts index d6cc800d34..731e9793e5 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts @@ -1,9 +1,3 @@ -import type { - ChartData, - ChartDataset, - ChartOptions, - ScatterDataPoint, -} from "chart.js"; import { endOfToday, isToday, startOfToday } from "date-fns"; import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; @@ -11,8 +5,8 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import type { BarSeriesOption } from "echarts/charts"; import { getEnergyColor } from "./common/color"; -import { formatNumber } from "../../../../common/number/format_number"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import type { @@ -31,7 +25,12 @@ import type { HomeAssistant } from "../../../../types"; import type { LovelaceCard } from "../../types"; import type { EnergyWaterGraphCardConfig } from "../types"; import { hasConfigChanged } from "../../common/has-changed"; -import { getCommonOptions } from "./common/energy-chart-options"; +import { + fillDataGapsAndRoundCaps, + getCommonOptions, +} from "./common/energy-chart-options"; +import type { ECOption } from "../../../../resources/echarts"; +import { formatNumber } from "../../../../common/number/format_number"; @customElement("hui-energy-water-graph-card") export class HuiEnergyWaterGraphCard @@ -42,9 +41,7 @@ export class HuiEnergyWaterGraphCard @state() private _config?: EnergyWaterGraphCardConfig; - @state() private _chartData: ChartData = { - datasets: [], - }; + @state() private _chartData: BarSeriesOption[] = []; @state() private _start = startOfToday(); @@ -111,7 +108,7 @@ export class HuiEnergyWaterGraphCard )} chart-type="bar" > - ${!this._chartData.datasets.length + ${!this._chartData.length ? html`
${isToday(this._start) ? this.hass.localize("ui.panel.lovelace.cards.energy.no_data") @@ -125,6 +122,12 @@ export class HuiEnergyWaterGraphCard `; } + private _formatTotal = (total: number) => + this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_water_graph.total_consumed", + { num: formatNumber(total, this.hass.locale), unit: this._unit } + ); + private _createOptions = memoizeOne( ( start: Date, @@ -134,51 +137,26 @@ export class HuiEnergyWaterGraphCard unit?: string, compareStart?: Date, compareEnd?: Date - ): ChartOptions => { - const commonOptions = getCommonOptions( + ): ECOption => + getCommonOptions( start, end, locale, config, unit, compareStart, - compareEnd - ); - const options: ChartOptions = { - ...commonOptions, - plugins: { - ...commonOptions.plugins, - tooltip: { - ...commonOptions.plugins!.tooltip, - callbacks: { - ...commonOptions.plugins!.tooltip!.callbacks, - footer: (contexts) => { - if (contexts.length < 2) { - return []; - } - let total = 0; - for (const context of contexts) { - total += (context.dataset.data[context.dataIndex] as any).y; - } - if (total === 0) { - return []; - } - return [ - this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_water_graph.total_consumed", - { num: formatNumber(total, locale), unit } - ), - ]; - }, - }, - }, - }, - }; - return options; - } + compareEnd, + this._formatTotal + ) ); private async _getStatistics(energyData: EnergyData): Promise { + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + const waterSources: WaterSourceTypeEnergyPreference[] = energyData.prefs.energy_sources.filter( (source) => source.type === "water" @@ -186,10 +164,32 @@ export class HuiEnergyWaterGraphCard this._unit = getEnergyWaterUnit(this.hass); - const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const datasets: BarSeriesOption[] = []; const computedStyles = getComputedStyle(this); + if (energyData.statsCompare) { + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + energyData.statsMetadata, + waterSources, + computedStyles, + true + ) + ); + } else { + // add empty dataset so compare bars are first + // `stack: water` so it doesn't take up space yet + const firstId = waterSources[0]?.stat_energy_from ?? "placeholder"; + datasets.push({ + id: "compare-" + firstId, + type: "bar", + stack: "water", + data: [], + }); + } + datasets.push( ...this._processDataSet( energyData.stats, @@ -199,38 +199,8 @@ export class HuiEnergyWaterGraphCard ) ); - if (energyData.statsCompare) { - // Add empty dataset to align the bars - datasets.push({ - order: 0, - data: [], - }); - datasets.push({ - order: 999, - data: [], - xAxisID: "xAxisCompare", - }); - - datasets.push( - ...this._processDataSet( - energyData.statsCompare, - energyData.statsMetadata, - waterSources, - computedStyles, - true - ) - ); - } - - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._compareStart = energyData.startCompare; - this._compareEnd = energyData.endCompare; - - this._chartData = { - datasets, - }; + fillDataGapsAndRoundCaps(datasets); + this._chartData = datasets; } private _processDataSet( @@ -240,56 +210,62 @@ export class HuiEnergyWaterGraphCard computedStyles: CSSStyleDeclaration, compare = false ) { - const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + const data: BarSeriesOption[] = []; + const compareOffset = compare + ? this._start.getTime() - this._compareStart!.getTime() + : 0; waterSources.forEach((source, idx) => { let prevStart: number | null = null; - const waterConsumptionData: ScatterDataPoint[] = []; + const waterConsumptionData: BarSeriesOption["data"] = []; // Process water consumption data. if (source.stat_energy_from in statistics) { const stats = statistics[source.stat_energy_from]; - let end; - for (const point of stats) { - if (point.change === null || point.change === undefined) { + if ( + point.change === null || + point.change === undefined || + point.change === 0 + ) { continue; } if (prevStart === point.start) { continue; } - const date = new Date(point.start); - waterConsumptionData.push({ - x: date.getTime(), - y: point.change, - }); + const dataPoint = [point.start, point.change]; + if (compare) { + dataPoint[2] = dataPoint[0]; + dataPoint[0] += compareOffset; + } + waterConsumptionData.push(dataPoint); prevStart = point.start; - end = point.end; - } - if (waterConsumptionData.length === 1) { - waterConsumptionData.push({ - x: end, - y: 0, - }); } } data.push({ - label: getStatisticLabel( + type: "bar", + id: compare + ? "compare-" + source.stat_energy_from + : source.stat_energy_from, + name: getStatisticLabel( this.hass, source.stat_energy_from, statisticsMetaData[source.stat_energy_from] ), - borderColor: getEnergyColor( - computedStyles, - this.hass.themes.darkMode, - false, - compare, - "--energy-water-color", - idx - ), - backgroundColor: getEnergyColor( + barMaxWidth: 50, + itemStyle: { + borderColor: getEnergyColor( + computedStyles, + this.hass.themes.darkMode, + false, + compare, + "--energy-water-color", + idx + ), + }, + color: getEnergyColor( computedStyles, this.hass.themes.darkMode, true, @@ -298,9 +274,7 @@ export class HuiEnergyWaterGraphCard idx ), data: waterConsumptionData, - order: 1, - stack: "water", - xAxisID: compare ? "xAxisCompare" : undefined, + stack: compare ? "compare" : "water", }); }); return data; diff --git a/src/panels/lovelace/cards/hui-history-graph-card.ts b/src/panels/lovelace/cards/hui-history-graph-card.ts index 51b0d52372..4a6290e4c6 100644 --- a/src/panels/lovelace/cards/hui-history-graph-card.ts +++ b/src/panels/lovelace/cards/hui-history-graph-card.ts @@ -64,6 +64,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { getGridOptions(): LovelaceGridOptions { return { columns: 12, + rows: 6, min_columns: 6, min_rows: (this._config?.entities?.length || 1) * 2, }; @@ -280,6 +281,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { .minYAxis=${this._config.min_y_axis} .maxYAxis=${this._config.max_y_axis} .fitYData=${this._config.fit_y_data || false} + height="100%" > `}
@@ -289,6 +291,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { static styles = css` ha-card { + display: flex; + flex-direction: column; height: 100%; } .card-header { @@ -302,10 +306,14 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { } .content { padding: 16px; + flex: 1; } .has-header { padding-top: 0; } + state-history-charts { + height: 100%; + } `; } diff --git a/src/panels/lovelace/cards/hui-statistics-graph-card.ts b/src/panels/lovelace/cards/hui-statistics-graph-card.ts index cb8a351493..9733df6eac 100644 --- a/src/panels/lovelace/cards/hui-statistics-graph-card.ts +++ b/src/panels/lovelace/cards/hui-statistics-graph-card.ts @@ -131,6 +131,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { getGridOptions(): LovelaceGridOptions { return { columns: 12, + rows: 5, min_columns: 8, min_rows: 4, }; @@ -277,6 +278,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { .fitYData=${this._config.fit_y_data || false} .hideLegend=${this._config.hide_legend || false} .logarithmicScale=${this._config.logarithmic_scale || false} + .daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW} + height="100%" >
@@ -352,14 +355,20 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { static styles = css` ha-card { + display: flex; + flex-direction: column; height: 100%; } .content { padding: 16px; + flex: 1; } .has-header { padding-top: 0; } + statistics-chart { + height: 100%; + } `; } diff --git a/src/resources/chartjs.ts b/src/resources/chartjs.ts deleted file mode 100644 index b7b74fed99..0000000000 --- a/src/resources/chartjs.ts +++ /dev/null @@ -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 -); diff --git a/src/resources/echarts.ts b/src/resources/echarts.ts new file mode 100644 index 0000000000..df393a92fe --- /dev/null +++ b/src/resources/echarts.ts @@ -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; diff --git a/src/translations/en.json b/src/translations/en.json index 3647185cfd..118aa04dea 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -834,8 +834,6 @@ "duration": "Duration", "source_history": "Source: History", "source_stats": "Source: Long term statistics", - "zoom_hint": "Use ctrl + scroll to zoom in/out", - "zoom_hint_mac": "Use ⌘ + scroll to zoom in/out", "zoom_reset": "Reset zoom" }, "map": { diff --git a/src/util/text.ts b/src/util/text.ts new file mode 100644 index 0000000000..e51b2eef0c --- /dev/null +++ b/src/util/text.ts @@ -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; +} diff --git a/yarn.lock b/yarn.lock index 7f70b2e5d9..951ad31deb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2174,13 +2174,6 @@ __metadata: languageName: node linkType: hard -"@kurkle/color@npm:^0.3.0": - version: 0.3.4 - resolution: "@kurkle/color@npm:0.3.4" - checksum: 10/a15bfb55eec4ff92ee27cdacff54e85c7497ec1f9e8e0e8eaec3d39320cc90b82eb7f716d92792a8235fad1617003c6e0c0d2cd07f6a79b7b138ca17ec5eb6e7 - languageName: node - linkType: hard - "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.5 resolution: "@leichtgewicht/ip-codec@npm:2.0.5" @@ -4650,7 +4643,7 @@ __metadata: languageName: node linkType: hard -"@types/hammerjs@npm:^2.0.36, @types/hammerjs@npm:^2.0.45": +"@types/hammerjs@npm:^2.0.36": version: 2.0.46 resolution: "@types/hammerjs@npm:2.0.46" checksum: 10/1b6502d668f45ca49fb488c01f7938d3aa75e989d70c64801c8feded7d659ca1a118f745c1b604d220efe344c93231767d5cc68c05e00e069c14539b6143cfd9 @@ -6434,27 +6427,6 @@ __metadata: languageName: node linkType: hard -"chart.js@npm:4.4.7": - version: 4.4.7 - resolution: "chart.js@npm:4.4.7" - dependencies: - "@kurkle/color": "npm:^0.3.0" - checksum: 10/f80c5e61adf8118d71d189db431f9bf82864e42d19d1c19008afdcf5e8933545f1f8a8662a4e7809f21a518781f12f2242657864c863016303cbf63a2aade72b - languageName: node - linkType: hard - -"chartjs-plugin-zoom@npm:2.2.0": - version: 2.2.0 - resolution: "chartjs-plugin-zoom@npm:2.2.0" - dependencies: - "@types/hammerjs": "npm:^2.0.45" - hammerjs: "npm:^2.0.8" - peerDependencies: - chart.js: ">=3.2.0" - checksum: 10/4a549b1b21ed5433f9ba67038d6176ed545b2881521e12d6b8024cd2ab08fb008c36fe388ab2ac7ee2ac334bf44a8d785703570388fa0e0b4c22c18602536f9c - languageName: node - linkType: hard - "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -7355,6 +7327,16 @@ __metadata: languageName: node linkType: hard +"echarts@npm:5.6.0": + version: 5.6.0 + resolution: "echarts@npm:5.6.0" + dependencies: + tslib: "npm:2.3.0" + zrender: "npm:5.6.1" + checksum: 10/e73344abb777fd8401c0b89a5d83b65c7a81a11540e2047d51f4aae9419baf4dc2524a5d9561f9ca0fe8d6b432c58b7a1d518f2a4338041046506db8257a1332 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -9038,13 +9020,6 @@ __metadata: languageName: node linkType: hard -"hammerjs@npm:^2.0.8": - version: 2.0.8 - resolution: "hammerjs@npm:2.0.8" - checksum: 10/9155d056f252ef35e8ca258dbb5ee2c9d8794f6805d083da7d1d9763d185e3e149459ecc2b36ccce584e3cd5f099fd9fa55056e3bcc7724046390f2e5ae25815 - languageName: node - linkType: hard - "handle-thing@npm:^2.0.0": version: 2.0.1 resolution: "handle-thing@npm:2.0.1" @@ -9253,8 +9228,6 @@ __metadata: babel-plugin-template-html-minifier: "npm:4.1.0" barcode-detector: "npm:2.3.1" browserslist-useragent-regexp: "npm:4.1.3" - chart.js: "npm:4.4.7" - chartjs-plugin-zoom: "npm:2.2.0" color-name: "npm:2.0.0" comlink: "npm:4.4.2" core-js: "npm:3.40.0" @@ -9265,6 +9238,7 @@ __metadata: deep-freeze: "npm:0.0.1" del: "npm:8.0.0" dialog-polyfill: "npm:0.5.6" + echarts: "npm:5.6.0" element-internals-polyfill: "npm:1.3.12" eslint: "npm:9.18.0" eslint-config-airbnb-base: "npm:15.0.0" @@ -14179,7 +14153,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": +"tslib@npm:^2.3.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -15922,6 +15896,15 @@ __metadata: languageName: node linkType: hard +"zrender@npm:5.6.1": + version: 5.6.1 + resolution: "zrender@npm:5.6.1" + dependencies: + tslib: "npm:2.3.0" + checksum: 10/25dfd476be243f051614f131675855d184eb05e8b9a39dc41146a3a553a17aad5ceba77e166d525be9c4adc2bb237c56dfdfb3456667940bbeddb05eef3deac7 + languageName: node + linkType: hard + "zxing-wasm@npm:1.3.4": version: 1.3.4 resolution: "zxing-wasm@npm:1.3.4"