Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov f291207b2f Zero-fill energy chart buckets so sparse data keeps correct axis and bar width 2026-07-03 08:41:49 +03:00
17 changed files with 3857 additions and 547 deletions
+21 -3
View File
@@ -1,8 +1,16 @@
import type { BarSeriesOption } from "echarts/types/dist/shared";
/**
* `extraBuckets` (only used when `stacked`) seeds the bucket union with the
* expected statistics grid so sparse datasets get zero-filled across the whole
* range, including past their last real point. Without it, buckets are only
* derived from the data and trailing buckets are never filled (legacy
* behavior, kept for callers that don't pass a grid).
*/
export function fillDataGapsAndRoundCaps(
datasets: BarSeriesOption[],
stacked = true
stacked = true,
extraBuckets?: number[]
) {
if (!stacked) {
// For non-stacked charts, we can simply apply an overall border to each stack
@@ -44,6 +52,7 @@ export function fillDataGapsAndRoundCaps(
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
.concat(extraBuckets ?? [])
)
).sort((a, b) => a - b);
@@ -61,9 +70,18 @@ export function fillDataGapsAndRoundCaps(
const x = item.value?.[0];
const stack = datasets[i].stack ?? "";
if (x === undefined) {
continue;
// Past the end of this dataset's data. Only append trailing buckets
// when an explicit grid was provided; originally-empty datasets
// (e.g. compare placeholders) stay empty either way.
if (
dataPoint !== undefined ||
extraBuckets === undefined ||
!datasets[i].data!.length
) {
continue;
}
}
if (Number(x) !== bucket) {
if (x === undefined || Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
-68
View File
@@ -1,68 +0,0 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast, 150ms)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8, 32px);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8, 32px);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
+269 -398
View File
@@ -1,8 +1,9 @@
import { mdiViewSplitHorizontal, mdiViewSplitVertical } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
@@ -10,11 +11,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-label";
import "../../../../components/ha-spinner";
import "../../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
@@ -53,18 +50,11 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_functions", path: "/template-functions/" },
];
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
const DEFAULT_SPLIT_POSITION = 50;
type SplitOrientation = "horizontal" | "vertical";
@customElement("tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@state() private _error?: string;
@@ -76,9 +66,9 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@state() private _descriptionExpanded = false;
@state() private _splitOrientation: SplitOrientation = "horizontal";
@query("ha-tip") private _editorTip?: HTMLElement;
private _template = "";
@@ -88,6 +78,8 @@ class HaPanelDevTemplate extends LitElement {
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
@@ -98,25 +90,18 @@ class HaPanelDevTemplate extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
this._tipResizeObserver?.disconnect();
this._tipResizeObserver = undefined;
}
protected firstUpdated() {
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
this._template = localStorage[STORAGE_KEY_TEMPLATE];
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
} else {
this._template = DEMO_TEMPLATE;
}
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this._splitPosition = parsed;
}
}
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
this._splitOrientation = "vertical";
}
this._subscribeTemplate();
this._observeTipHeight();
this._inited = true;
}
@@ -129,20 +114,15 @@ class HaPanelDevTemplate extends LitElement {
: "dict"
: type;
const editorCard = this._renderEditorCard();
const resultCard = this._renderResultCard(type, resultType);
// On narrow viewports side-by-side is too cramped, so force the (still
// resizable) stacked layout and hide the orientation toggle.
const orientation = this.narrow ? "vertical" : this._splitOrientation;
return html`
<div class="about">
<div class="content">
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
@@ -184,243 +164,187 @@ class HaPanelDevTemplate extends LitElement {
</div>
</ha-expansion-panel>
</div>
<ha-split-panel
class="panes ${orientation === "vertical" ? "vertical" : ""}"
.position=${this._splitPosition}
.orientation=${orientation}
snap="50%"
@wa-reposition=${this._splitRepositioned}
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<div slot="start" class="pane">${editorCard}</div>
<div slot="end" class="pane">${resultCard}</div>
${this.narrow ? nothing : this._renderOrientationToggle()}
</ha-split-panel>
`;
}
private _renderOrientationToggle() {
const label = this.hass.localize(
this._splitOrientation === "vertical"
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
: "ui.panel.config.tools.tabs.templates.layout_stacked"
);
return html`
<button
type="button"
slot="divider"
class="divider-toggle"
.title=${label}
aria-label=${label}
@mousedown=${this._dividerPointerDown}
@touchstart=${this._dividerPointerDown}
@click=${this._dividerClick}
>
<ha-svg-icon
.path=${this._splitOrientation === "vertical"
? mdiViewSplitVertical
: mdiViewSplitHorizontal}
></ha-svg-icon>
</button>
`;
}
private _renderEditorCard() {
return html`
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize("ui.panel.config.tools.tabs.templates.reset")}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
</ha-tip>
</ha-card>
`;
}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.reset"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
private _renderResultCard(type: string, resultType: string) {
const showEmptyState =
!this._error && !this._rendering && !this._template?.trim();
return html`
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""}
${this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing}
${showEmptyState
? html`<div class="empty">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_placeholder"
)}
</div>`
: this._templateResult
? html`
<ha-label dense>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</ha-label>
<pre class="rendered">
${type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result}</pre
>
${this._templateResult.listeners.time
? html`
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.time"
)}
</p>
`
: ""}
${!this._templateResult.listeners
? nothing
: this._templateResult.listeners.all
? html`
<p class="all_listeners">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.all_listeners"
)}
</p>
`
: this._templateResult.listeners.domains.length ||
this._templateResult.listeners.entities.length
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
this._templateResult
? html`<pre
class="rendered ${classMap({
[resultType]: resultType,
})}"
>
${
type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result
}</pre>
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</p>
${
this._templateResult.listeners.time
? html`
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.listeners"
"ui.panel.config.tools.tabs.templates.time"
)}
</p>
<ul>
${this._templateResult.listeners.domains
.sort()
.map(
(domain) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.templates.domain"
)}</b
>: ${domain}
</li>
`
)}
${this._templateResult.listeners.entities
.sort()
.map(
(entity_id) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.templates.entity"
)}</b
>: ${entity_id}
</li>
`
)}
</ul>
`
: !this._templateResult.listeners.time
? html`<span class="all_listeners">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.no_listeners"
)}
</span>`
: nothing}
`
: nothing}
</div>
</ha-card>
: ""
}
${
!this._templateResult.listeners
? nothing
: this._templateResult.listeners.all
? html`
<p class="all_listeners">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.all_listeners"
)}
</p>
`
: this._templateResult.listeners.domains.length ||
this._templateResult.listeners.entities.length
? html`
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.listeners"
)}
</p>
<ul>
${this._templateResult.listeners.domains
.sort()
.map(
(domain) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.templates.domain"
)}</b
>: ${domain}
</li>
`
)}
${this._templateResult.listeners.entities
.sort()
.map(
(entity_id) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.templates.entity"
)}</b
>: ${entity_id}
</li>
`
)}
</ul>
`
: !this._templateResult.listeners.time
? html`<span class="all_listeners">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.no_listeners"
)}
</span>`
: nothing
}`
: nothing
}
</div>
</ha-card>
</div>
`;
}
private _splitRepositioned(ev: Event) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
private _toggleOrientation() {
this._splitOrientation =
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
if (this._inited) {
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
}
}
private _dividerPointerStart?: { x: number; y: number };
private _dividerPointerDown = (ev: MouseEvent | TouchEvent) => {
const point = "touches" in ev ? ev.touches[0] : ev;
if (point) {
this._dividerPointerStart = { x: point.clientX, y: point.clientY };
}
};
private _dividerClick = (ev: MouseEvent) => {
const start = this._dividerPointerStart;
this._dividerPointerStart = undefined;
// Ignore the click that ends a drag-resize; only a genuine (still) click
// toggles the orientation.
if (start && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) > 5) {
private _observeTipHeight() {
if (!this._editorTip || this._tipResizeObserver) {
return;
}
this._toggleOrientation();
};
private _storeSplitPosition = debounce(
() => {
if (!this._inited) {
return;
this._tipResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ??
entries[0]?.contentRect.height;
if (height) {
this.style.setProperty("--tip-height", `${height}px`);
}
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
});
this._tipResizeObserver.observe(this._editorTip);
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
}
static get styles(): CSSResultGroup {
return [
@@ -428,141 +352,73 @@ ${type === "object"
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
}
.about {
flex: none;
.content {
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.about a {
color: var(--primary-color);
}
.panes {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--ha-space-4);
--ha-split-panel-min: 20%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
/* On wide viewports we slot our own handle (the orientation toggle)
into the divider, so hide the default grip. On narrow there is no
toggle, so keep the default grip as the resize affordance. */
:host(:not([narrow])) .panes {
--ha-split-panel-grip-display: none;
}
/* Orientation toggle that lives on the divider and doubles as a grip.
Clicks toggle orientation; dragging the divider elsewhere resizes. */
.divider-toggle {
position: relative;
z-index: 1;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
background-color: var(--card-background-color);
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 16px;
transition:
color var(--ha-animation-duration-fast, 150ms) ease-out,
border-color var(--ha-animation-duration-fast, 150ms) ease-out;
}
@media (hover: hover) {
.divider-toggle:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.divider-toggle:focus-visible {
outline: none;
color: var(--primary-color);
border-color: var(--primary-color);
}
.pane {
display: flex;
min-width: 0;
height: 100%;
box-sizing: border-box;
}
.pane[slot="start"] {
padding-inline-end: var(--ha-space-4);
}
.pane[slot="end"] {
padding-inline-start: var(--ha-space-4);
}
.panes.vertical .pane[slot="start"] {
padding-inline-end: 0;
padding-block-end: var(--ha-space-4);
}
.panes.vertical .pane[slot="end"] {
padding-inline-start: 0;
padding-block-start: var(--ha-space-4);
}
.pane ha-card {
flex: 1;
min-width: 0;
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--tip-height-minimal: calc(
var(--mdc-icon-size, 24px) + var(--ha-space-4)
);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--tip-height,
var(--tip-height-minimal)
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
2
);
}
ha-card {
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
.edit-pane .card-content {
flex: 1;
min-height: 0;
display: flex;
}
.edit-pane ha-code-editor {
flex: 1;
min-height: 0;
width: 100%;
--code-mirror-height: 100%;
}
.render-pane .card-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
user-select: text;
margin-bottom: var(--ha-space-4);
}
.edit-pane {
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.content.horizontal > * {
width: 50%;
margin-bottom: 0px;
}
.render-spinner {
position: absolute;
top: var(--ha-space-2);
@@ -572,24 +428,10 @@ ${type === "object"
}
ha-alert {
margin-bottom: var(--ha-space-2);
display: block;
}
.render-pane ha-label {
align-self: flex-start;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -597,7 +439,6 @@ ${type === "object"
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-2);
margin-top: 0;
margin-bottom: 0;
@@ -606,7 +447,7 @@ ${type === "object"
p,
ul {
margin-block: 0;
margin-block-end: 0;
}
.description > p {
margin-block-start: 0;
@@ -627,6 +468,26 @@ ${type === "object"
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
}
.content.horizontal .render-pane .card-content {
overflow: auto;
max-height: calc(
var(--code-mirror-max-height) +
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
);
}
.content.horizontal .render-pane {
overflow: hidden;
padding-bottom: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.all_listeners {
color: var(--warning-color);
}
@@ -647,6 +508,12 @@ ${type === "object"
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;
}
}
.card-actions {
display: flex;
}
@@ -748,7 +615,7 @@ ${type === "object"
if (!this._inited) {
return;
}
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
localStorage["panel-dev-template-template"] = this._template;
}
private async _restoreDemo() {
@@ -764,7 +631,7 @@ ${type === "object"
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage[STORAGE_KEY_TEMPLATE];
delete localStorage["panel-dev-template-template"];
}
private async _clear() {
@@ -780,8 +647,12 @@ ${type === "object"
}
this._unsubscribeTemplate();
this._template = "";
// An empty template shows the placeholder empty state.
this._templateResult = undefined;
// Reset to empty result. Setting to 'undefined' results in a different visual
// behaviour compared to manually emptying the template input box.
this._templateResult = {
result: "",
listeners: { all: false, entities: [], domains: [], time: false },
};
}
}
@@ -12,12 +12,14 @@ import {
startOfMonth,
addYears,
addMonths,
addMinutes,
addHours,
startOfDay,
addDays,
subDays,
} from "date-fns";
import type {
BarSeriesOption,
CallbackDataParams,
LineSeriesOption,
TopLevelFormatterParams,
@@ -377,12 +379,97 @@ const PERIOD_MS: Record<string, number> = {
/**
* Offset from a period's start to its midpoint, for centering sub-daily bars
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
* the start. Derived from the period, not from the data, so the first/only
* bucket centers identically to every other bucket. (Previously estimated from
* the gap between the first two entries, which collapsed to 0 with one bucket.)
* the start.
*
* `measuredGap` is the gap between the first two entries, when available. It
* adapts the offset to data that is finer-grained than the nominal period
* (e.g. external forecast data), but is clamped to the nominal period so
* sparse data (gaps between readings) can't inflate the offset, and a lone
* bucket (no gap to measure) still centers on the nominal midpoint.
*/
export function getPeriodMidpointOffset(period: string): number {
return (PERIOD_MS[period] ?? 0) / 2;
export function getPeriodMidpointOffset(
period: string,
measuredGap?: number
): number {
const nominal = PERIOD_MS[period] ?? 0;
return (measuredGap ? Math.min(measuredGap, nominal) : nominal) / 2;
}
/**
* Generate the expected statistics-bucket grid across [start, end) so sparse
* data can be zero-filled. Without a dense grid, ECharts derives the bar band
* width from the minimum gap between data points: sparse data yields
* oversized bars, and a single point makes ECharts expand the time axis by
* ±40% of its span, ignoring the configured min/max.
*
* The grid is anchored on the first real data bucket of a non-compare bar
* series rather than on `start`: recorder buckets are UTC-aligned, so in
* half-hour timezones they don't sit on local period boundaries. Stepping
* from a real bucket keeps generated buckets exactly on the data's grid
* (midpoints for sub-daily periods, period starts otherwise). Returns an
* empty array when there is no data to anchor on.
*/
export function generateFillBuckets(
datasets: BarSeriesOption[],
start: Date,
end: Date,
period: "5minute" | "hour" | "day" | "month"
): number[] {
let anchor: number | undefined;
for (const dataset of datasets) {
if (
dataset.type !== "bar" ||
String(dataset.id).startsWith("compare-") ||
!dataset.data?.length
) {
continue;
}
const first = dataset.data[0];
const value =
first && typeof first === "object" && "value" in first
? first.value
: first;
const x = Number((value as number[])?.[0]);
if (!Number.isNaN(x)) {
anchor = x;
break;
}
}
if (anchor === undefined) {
return [];
}
const anchorDate = new Date(anchor);
// Step relative to the anchor (not iteratively) so month-length clamping
// and DST shifts can't accumulate drift.
const bucketAt = (n: number): number =>
(period === "5minute"
? addMinutes(anchorDate, 5 * n)
: period === "hour"
? addHours(anchorDate, n)
: period === "day"
? addDays(anchorDate, n)
: addMonths(anchorDate, n)
).getTime();
const startMs = start.getTime();
const endMs = end.getTime();
const buckets: number[] = [];
for (let n = 0; ; n--) {
const ts = bucketAt(n);
if (ts < startMs) {
break;
}
buckets.push(ts);
}
for (let n = 1; ; n++) {
const ts = bucketAt(n);
if (ts >= endMs) {
break;
}
buckets.push(ts);
}
return buckets;
}
export interface UntrackedSplit {
@@ -23,6 +23,7 @@ import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
splitUntrackedConsumption,
@@ -238,15 +239,15 @@ function processUntracked(
const sortedTimes = Object.keys(consumptionData.used_total).sort(
(a, b) => Number(a) - Number(b)
);
// Only start timestamps available here, so center sub-daily bars using the
// gap between the first two entries. With a lone first-of-day bucket there is
// no gap to measure, so fall back to the nominal period midpoint — which
// matches the device bars' computeStatMidpoint instead of collapsing to the
// period start and splitting into a second stack.
const periodOffset =
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
: getPeriodMidpointOffset(period);
// Only start timestamps available here, so center sub-daily bars from the
// gap between the first two entries, clamped to the nominal period so
// sparse or lone buckets stay centered on the same grid as the device bars.
const periodOffset = getPeriodMidpointOffset(
period,
sortedTimes.length >= 2
? Number(sortedTimes[1]) - Number(sortedTimes[0])
: undefined
);
sortedTimes.forEach((time) => {
const ts = Number(time);
const x = compare
@@ -515,7 +516,11 @@ export function generateEnergyDevicesDetailGraphData(
}
}
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
return {
@@ -12,6 +12,7 @@ import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
generateFillBuckets,
getCompareTransform,
} from "./common/energy-chart-options";
@@ -113,7 +114,11 @@ export function generateEnergyGasGraphData(
)
);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(datasets, start, end, period)
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const chartData = datasets;
const total = processTotal(energyData.stats, gasSources);
@@ -13,6 +13,7 @@ import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
} from "./common/energy-chart-options";
@@ -117,7 +118,11 @@ export function generateEnergySolarGraphData(
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
fillDataGapsAndRoundCaps(
datasets as BarSeriesOption[],
true,
generateFillBuckets(datasets as BarSeriesOption[], start, end, period)
);
if (forecasts) {
datasets.push(
@@ -322,20 +327,18 @@ function processForecast(
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries; with a lone first bucket there is no
// gap to measure, so fall back to the nominal period midpoint.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: getPeriodMidpointOffset(period);
}
// Center forecast points for sub-daily periods from the gap between
// the first two entries, clamped to the nominal period so sparse or
// lone forecast buckets still align with the bars.
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
const forecastOffset = getPeriodMidpointOffset(
period,
forecastTimes.length >= 2
? forecastTimes[1] - forecastTimes[0]
: undefined
);
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
@@ -37,6 +37,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCommonOptions,
getCompareTransform,
getPeriodMidpointOffset,
@@ -450,7 +451,16 @@ export class HuiEnergyUsageGraphCard
// @ts-expect-error
datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(
datasets,
this._start,
this._end,
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._legendData = this._getLegendData(datasets);
@@ -596,15 +606,14 @@ export class HuiEnergyUsageGraphCard
const uniqueKeys = summedData.timestamps;
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
// available here, so estimate midpoint from the gap between the first two
// entries; with a lone first-of-day bucket there is no gap to measure, so
// fall back to the nominal period midpoint so the bar stays centered.
// Only start timestamps available here, so center sub-daily bars from the
// gap between the first two entries, clamped to the nominal period so
// sparse or lone buckets stay centered on the same grid as dense data.
const period = getSuggestedPeriod(this._start, this._end);
const periodOffset =
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
? (uniqueKeys[1] - uniqueKeys[0]) / 2
: getPeriodMidpointOffset(period);
const periodOffset = getPeriodMidpointOffset(
period,
uniqueKeys.length >= 2 ? uniqueKeys[1] - uniqueKeys[0] : undefined
);
const compareTransform = getCompareTransform(
this._start,
@@ -31,6 +31,7 @@ import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
@@ -263,7 +264,16 @@ export class HuiEnergyWaterGraphCard
)
);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(
datasets,
this._start,
this._end,
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, waterSources);
-3
View File
@@ -3910,9 +3910,6 @@
"about": "About templates",
"editor": "Template editor",
"result": "Result",
"result_placeholder": "Your template result will appear here.",
"layout_stacked": "Drag to resize, click for stacked view",
"layout_side_by_side": "Drag to resize, click for side-by-side view",
"reset": "Reset to demo template",
"confirm_reset": "Do you want to reset your current template back to the demo template?",
"confirm_clear": "Do you want to clear your current template?",
@@ -25,8 +25,8 @@ exports[`generateEnergyGasGraphData > large 5-minute payload digest is stable (c
"unit",
"yAxisFractionDigits",
],
"numberCount": 312488,
"numberSum": "2.26308627397e+17",
"numberCount": 392840,
"numberSum": "2.71986228397e+17",
"type": "object",
}
`;
@@ -52,6 +52,15 @@ exports[`generateEnergyGasGraphData > matches snapshot for a single gas source (
"color": "#1b7ea07F",
"cursor": "default",
"data": [
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -3376,6 +3385,438 @@ exports[`generateEnergyGasGraphData > matches snapshot with compare data 1`] = `
0.287,
1704063600000,
],
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.gas_consumption_0",
"itemStyle": {
@@ -4110,6 +4551,438 @@ exports[`generateEnergyGasGraphData > matches snapshot with compare data 1`] = `
1704063600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.gas_consumption_1",
"itemStyle": {
@@ -11,8 +11,8 @@ exports[`generateEnergySolarGraphData > large 5-minute payload digest is stable
"total",
"yAxisFractionDigits",
],
"numberCount": 232066,
"numberSum": "1.50908786888e+17",
"numberCount": 285622,
"numberSum": "1.81353699874e+17",
"type": "object",
}
`;
@@ -1008,6 +1008,15 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
1705190400000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1705276800000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -1263,6 +1272,15 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
1706745600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1706832000000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -1639,6 +1657,438 @@ exports[`generateEnergySolarGraphData > matches snapshot for two solar sources w
0.287,
1704063600000,
],
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.solar_production",
"itemStyle": {
@@ -2373,6 +2823,438 @@ exports[`generateEnergySolarGraphData > matches snapshot for two solar sources w
1704063600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.solar_production_1",
"itemStyle": {
@@ -5594,6 +6476,15 @@ exports[`generateEnergySolarGraphData > matches snapshot with hourly forecast da
1704085200000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -5,7 +5,9 @@ import {
computeStatMidpoint,
fillDataGapsAndRoundCaps,
fillLineGaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
getSuggestedMax,
splitUntrackedConsumption,
} from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
@@ -498,6 +500,307 @@ describe("fillDataGapsAndRoundCaps", () => {
assert.equal(datasets[0].data!.length, 0);
});
it("does not fill trailing buckets without an explicit grid", () => {
// Legacy behavior pin: buckets past a dataset's last real point are
// only appended when extraBuckets is passed.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
{
type: "bar",
stack: "a",
data: [
[1000, 100],
[2000, 200],
],
},
];
fillDataGapsAndRoundCaps(datasets);
assert.equal(datasets[0].data!.length, 1);
assert.equal(datasets[1].data!.length, 2);
});
it("appends trailing zero buckets from the explicit grid", () => {
// The single-reading case: one real point in the first bucket must
// be padded with zero buckets across the whole grid, otherwise ECharts
// derives a degenerate bar band width and expands the time axis.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000, 4000]);
assert.equal(datasets[0].data!.length, 4);
assert.equal(getBarItem(datasets[0], 0).value[1], 10);
for (const index of [1, 2, 3]) {
const item = getBarItem(datasets[0], index);
assert.equal(item.value[0], 1000 * (index + 1));
assert.equal(item.value[1], 0);
assert.equal(item.itemStyle.borderWidth, 0);
}
});
it("fills leading and middle buckets from the explicit grid", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[3000, 30]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000, 4000]);
assert.equal(datasets[0].data!.length, 4);
assert.deepEqual(
datasets[0].data!.map((item) => getX(item)),
[1000, 2000, 3000, 4000]
);
assert.deepEqual(
datasets[0].data!.map((item) => getY(item)),
[0, 0, 30, 0]
);
});
it("keeps originally-empty datasets empty when a grid is passed", () => {
// Compare placeholder datasets must stay empty so no-data detection
// keeps working.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [],
},
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000]);
assert.equal(datasets[0].data!.length, 0);
assert.equal(datasets[1].data!.length, 2);
});
it("still rounds caps on the real bar when grid buckets are added", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[2000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000]);
const realItem = getBarItem(datasets[0], 1);
assert.equal(realItem.value[1], 10);
assert.deepEqual(realItem.itemStyle.borderRadius, [4, 4, 0, 0]);
// Zero fills get no border at all
assert.equal(getBarItem(datasets[0], 0).itemStyle.borderWidth, 0);
assert.equal(getBarItem(datasets[0], 2).itemStyle.borderWidth, 0);
});
});
describe("getPeriodMidpointOffset", () => {
const HOUR = 60 * 60 * 1000;
it("returns half the nominal period when no gap was measured", () => {
assert.equal(getPeriodMidpointOffset("hour"), HOUR / 2);
assert.equal(getPeriodMidpointOffset("5minute"), 2.5 * 60 * 1000);
});
it("returns 0 for daily and longer periods", () => {
assert.equal(getPeriodMidpointOffset("day"), 0);
assert.equal(getPeriodMidpointOffset("week"), 0);
assert.equal(getPeriodMidpointOffset("month"), 0);
// Even with a measured gap
assert.equal(getPeriodMidpointOffset("day", 24 * HOUR), 0);
});
it("uses half the measured gap for finer-grained data", () => {
// e.g. 5-minute data shown with an hourly period
assert.equal(
getPeriodMidpointOffset("hour", 5 * 60 * 1000),
2.5 * 60 * 1000
);
});
it("clamps the measured gap to the nominal period for sparse data", () => {
// e.g. two readings 12h apart in an hourly view
assert.equal(getPeriodMidpointOffset("hour", 12 * HOUR), HOUR / 2);
});
});
describe("generateFillBuckets", () => {
const HOUR = 60 * 60 * 1000;
// Tests run in TZ=Etc/UTC, so local time equals UTC here.
const start = new Date("2024-03-15T00:00:00.000Z");
const end = new Date("2024-03-16T00:00:00.000Z");
const barsAt = (xs: number[], id = "main"): BarSeriesOption => ({
type: "bar",
id,
data: xs.map((x) => [x, 1, x - HOUR / 2]),
});
it("expands a single hourly bucket to the full day grid", () => {
// Real bucket: 09:00-10:00 centered at 09:30
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets([barsAt([anchor])], start, end, "hour");
assert.equal(buckets.length, 24);
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime() + 0.5 * HOUR);
assert.equal(sorted[23], start.getTime() + 23.5 * HOUR);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], HOUR);
}
assert.include(buckets, anchor);
});
it("keeps the grid anchored on data not aligned to the range start", () => {
// Half-hour timezone simulation: recorder buckets sit at :30 local, so
// midpoints are on the whole hour. The grid must follow the data, not
// the local-midnight range start.
const anchor = start.getTime() + 10 * HOUR; // 09:30-10:30 bucket
const buckets = generateFillBuckets([barsAt([anchor])], start, end, "hour");
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime());
assert.equal(sorted[sorted.length - 1], start.getTime() + 23 * HOUR);
assert.isTrue(sorted.every((ts) => (ts - anchor) % HOUR === 0));
});
it("generates 5minute buckets", () => {
const fiveMin = 5 * 60 * 1000;
const anchor = start.getTime() + fiveMin / 2;
const shortEnd = new Date(start.getTime() + HOUR);
const buckets = generateFillBuckets(
[barsAt([anchor])],
start,
shortEnd,
"5minute"
);
assert.equal(buckets.length, 12);
const sorted = [...buckets].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], fiveMin);
}
});
it("generates day buckets at period starts", () => {
const weekEnd = new Date("2024-03-22T00:00:00.000Z");
const anchor = start.getTime() + 3 * 24 * HOUR; // day 4 of the range
const buckets = generateFillBuckets(
[barsAt([anchor])],
start,
weekEnd,
"day"
);
assert.equal(buckets.length, 7);
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime());
assert.equal(sorted[6], start.getTime() + 6 * 24 * HOUR);
});
it("generates month buckets with variable month lengths", () => {
const yearStart = new Date("2024-01-01T00:00:00.000Z");
const yearEnd = new Date("2025-01-01T00:00:00.000Z");
const anchor = Date.UTC(2024, 4, 1); // May 1st
const buckets = generateFillBuckets(
[barsAt([anchor])],
yearStart,
yearEnd,
"month"
);
assert.equal(buckets.length, 12);
const sorted = [...buckets].sort((a, b) => a - b);
for (let month = 0; month < 12; month++) {
assert.equal(sorted[month], Date.UTC(2024, month, 1));
}
});
it("ignores compare series and placeholders when picking the anchor", () => {
const compareAnchor = start.getTime() + 0.25 * HOUR; // off-grid transform
const mainAnchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[
{ type: "bar", id: "compare-placeholder", data: [] },
barsAt([compareAnchor], "compare-sensor.water"),
barsAt([mainAnchor], "sensor.water"),
],
start,
end,
"hour"
);
assert.include(buckets, mainAnchor);
assert.isTrue(
buckets.every((ts) => (ts - mainAnchor) % HOUR === 0),
"grid must be anchored on the main series"
);
});
it("skips empty main series and anchors on the next one with data", () => {
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[barsAt([], "sensor.empty"), barsAt([anchor], "sensor.water")],
start,
end,
"hour"
);
assert.equal(buckets.length, 24);
assert.include(buckets, anchor);
});
it("returns an empty grid when there is no data to anchor on", () => {
assert.deepEqual(generateFillBuckets([], start, end, "hour"), []);
assert.deepEqual(
generateFillBuckets(
[barsAt([], "sensor.empty"), barsAt([1000], "compare-sensor.water")],
start,
end,
"hour"
),
[]
);
});
it("reads the anchor from object-format data items", () => {
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[
{
type: "bar",
id: "main",
data: [{ value: [anchor, 1] }],
},
],
start,
end,
"hour"
);
assert.equal(buckets.length, 24);
assert.include(buckets, anchor);
});
});
describe("getCompareTransform", () => {
@@ -4,6 +4,7 @@
* optimization pass — see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import type { BarSeriesOption } from "echarts/charts";
import { generateEnergyGasGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
@@ -209,4 +210,156 @@ describe("generateEnergyGasGraphData", () => {
)
).toMatchSnapshot();
});
// Regression tests for #52938: sparse statistics (e.g. a meter that reports
// once per day) must be zero-filled across the whole range, otherwise
// ECharts derives the bar band width from the data gaps — a lone bucket
// makes it expand the time axis by ±40% of its span and draw an oversized
// bar.
describe("sparse data zero-fill", () => {
const HOUR = 60 * 60 * 1000;
const keepBuckets = (
energyData: ReturnType<typeof generateEnergyData>,
hourOffsets: number[]
) => {
const startMs = energyData.start.getTime();
const keep = new Set(hourOffsets.map((h) => startMs + h * HOUR));
return {
...energyData,
stats: Object.fromEntries(
Object.entries(energyData.stats).map(([id, rows]) => [
id,
rows.filter((row) => keep.has(row.start)),
])
),
};
};
const getX = (item: any): number => Number(item?.value?.[0] ?? item?.[0]);
const getY = (item: any): number => Number(item?.value?.[1] ?? item?.[1]);
it("fills the full day grid around a single mid-day bucket", () => {
const energyData = keepBuckets(
generateEnergyData(8, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[10]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
const nonZero = main.data!.filter((item) => getY(item) !== 0);
expect(nonZero).toHaveLength(1);
// The real bar stays centered on its bucket midpoint.
expect(getX(nonZero[0])).toBe(energyData.start.getTime() + 10.5 * HOUR);
// The compare placeholder stays empty (no-data detection).
const placeholder = result.chartData.find((dataset) =>
String(dataset.id).startsWith("compare-")
)!;
expect(placeholder.data).toHaveLength(0);
});
it("fills the gaps between sparse readings", () => {
const energyData = keepBuckets(
generateEnergyData(9, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[2, 14]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
expect(main.data!.filter((item) => getY(item) !== 0)).toHaveLength(2);
});
it("keeps datasets empty when there is no data at all", () => {
const energyData = keepBuckets(
generateEnergyData(10, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
for (const dataset of result.chartData) {
expect(dataset.data).toHaveLength(0);
}
});
it("propagates the grid to compare datasets", () => {
const dayMs = 24 * HOUR;
const base = generateEnergyData(11, {
days: 1,
period: "hour",
compare: true,
prefs: gasOnlyPrefs(1),
});
const energyData = {
...keepBuckets(base, [10]),
// The fixture doesn't set the compare range; provide it so compare
// rows are day-shifted onto the main axis like in the real dashboard.
startCompare: new Date(base.start.getTime() - dayMs),
endCompare: new Date(base.start.getTime()),
};
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const compare = result.chartData.find(
(dataset) => dataset.id === "compare-sensor.gas_consumption_0"
)!;
// Compare data is dense here, but it must be aligned to the same
// 24-bucket grid as the zero-filled main series.
assertDenseGrid(compare.data!, 24, HOUR);
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
});
function assertDenseGrid(
data: NonNullable<BarSeriesOption["data"]>,
length: number,
gap: number
) {
expect(data).toHaveLength(length);
const xs = data.map((item) => getX(item));
expect(new Set(xs).size).toBe(length);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i] - sorted[i - 1]).toBe(gap);
}
}
});
});
@@ -164,27 +164,26 @@ describe("generateEnergyDevicesDetailGraphData", () => {
).toMatchSnapshot();
});
// Regression test for #52937: at the start of the day only the first hour
// has data. The untracked/over-reported bars must center on the same period
// midpoint as the device bars so they stack as one bar instead of splitting
// into a second stack at the period start.
it("stacks untracked bars on the device bars for a lone first-of-day bucket", () => {
// Full-day range (so getSuggestedPeriod stays "hour") but keep only the
// first hourly bucket in every stat. gapChance: 0 makes the bucket dense.
const full = generateEnergyData(1, {
// Regression test for #52937/#52938: at the start of the day only the first
// hour has data. The untracked bars must center on the same period midpoint
// as the device bars (one stack, not two), and the whole day must be
// zero-filled so ECharts keeps the configured axis range instead of
// expanding it around the lone bucket.
it("keeps a lone first-of-day bucket on the shared zero-filled grid", () => {
const HOUR = 60 * 60 * 1000;
const full = generateEnergyData(12, {
days: 1,
period: "hour",
gapChance: 0,
prefs: buildPrefs(false),
});
const firstStart = full.start.getTime();
const energyData = {
...full,
stats: Object.fromEntries(
Object.entries(full.stats).map(
([id, values]) =>
[id, values.filter((s) => s.start === firstStart)] as const
)
Object.entries(full.stats).map(([id, rows]) => [
id,
rows.filter((row) => row.start === firstStart),
])
),
};
@@ -193,26 +192,29 @@ describe("generateEnergyDevicesDetailGraphData", () => {
energyData,
});
// Collect the display x of every bar across all series.
const xs = new Set<number>();
let nonEmptySeries = 0;
const nonZeroXs = new Set<number>();
for (const series of result.chartData) {
const points = series.data ?? [];
if (points.length) {
nonEmptySeries++;
if (!series.data?.length) {
continue;
}
for (const point of points as any[]) {
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
if (x != null) {
xs.add(Number(x));
// Every non-empty series covers the full day grid...
const xs = series.data.map((item: any) =>
Number(item?.value?.[0] ?? item?.[0])
);
assert.equal(xs.length, 24);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], HOUR);
}
for (const [index, item] of (series.data as any[]).entries()) {
const y = Number(item?.value?.[1] ?? item?.[1]);
if (y !== 0) {
nonZeroXs.add(xs[index]);
}
}
}
// Device bars + at least one untracked series are present...
assert.isAtLeast(nonEmptySeries, 2);
// ...and they all share a single x, so they render as one full stack.
assert.equal(xs.size, 1);
// ...and all real values stack on the single bucket midpoint.
assert.deepEqual([...nonZeroXs], [firstStart + HOUR / 2]);
});
// The seeded fixtures above all happen to produce fully-negative untracked
@@ -209,4 +209,57 @@ describe("generateEnergySolarGraphData", () => {
)
).toMatchSnapshot();
});
// Regression test for #52938: a lone statistics bucket must be zero-filled
// across the day so ECharts keeps the configured axis range, while forecast
// line series stay untouched by the bar-bucket fill.
it("zero-fills bars around a single bucket without touching forecast lines", () => {
const HOUR = 60 * 60 * 1000;
const base = generateEnergyData(7, {
days: 1,
period: "hour",
prefs: solarPrefs({ sources: 1, forecast: true }),
});
const startMs = base.start.getTime();
const energyData = {
...base,
stats: Object.fromEntries(
Object.entries(base.stats).map(([id, rows]) => [
id,
rows.filter((row) => row.start === startMs + 10 * HOUR),
])
),
};
const forecasts = buildForecasts(24, HOUR, ["entry_0"]);
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now,
});
const bars = result.chartData.find(
(d) => d.id === "sensor.solar_production"
)!;
const xs = bars.data!.map((item: any) =>
Number(item?.value?.[0] ?? item?.[0])
);
expect(xs).toHaveLength(24);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i] - sorted[i - 1]).toBe(HOUR);
}
const forecast = result.chartData.find((d) =>
String(d.id).startsWith("forecast-")
)!;
expect(forecast.data).toHaveLength(24);
// Forecast points are centered with the nominal half-period offset.
for (const [index, item] of (forecast.data as any[]).entries()) {
expect(Number(item?.value?.[0] ?? item?.[0])).toBe(
startMs + index * HOUR + HOUR / 2
);
}
});
});