Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov f8a2394df5 Recompute Y-axis tick precision when zooming charts 2026-07-03 10:13:07 +03:00
12 changed files with 474 additions and 502 deletions
@@ -16,6 +16,7 @@ import {
CLIMATE_MODE_CONFIGS,
generateStateHistoryChartLineData,
} from "./state-history-chart-line-data";
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -28,6 +29,10 @@ import { fireEvent } from "../../common/dom/fire_event";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
// Minimum width reserved for the Y-axis labels; also the value _yWidth is
// re-measured up from whenever the tick precision changes on zoom.
const MIN_Y_AXIS_WIDTH = 25;
// Used to recover the underlying entity_id from a legend dataset id.
// Kept in sync with the suffixes appended at dataset construction below
// for climate / water_heater / humidifier multi-attribute charts.
@@ -101,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25;
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
@state() private _visualMap?: VisualMapComponentOption[];
@@ -318,8 +323,18 @@ export class StateHistoryChartLine extends LitElement {
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
// Re-measure the gutter for the new precision so it can shrink
// again when zooming back out (_yWidth otherwise only grows).
this._yWidth = 0;
}
},
}),
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
@@ -448,7 +463,7 @@ export class StateHistoryChartLine extends LitElement {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
const width = Math.max(measureTextWidth(label, 12) + 5, MIN_Y_AXIS_WIDTH);
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
+17 -7
View File
@@ -34,6 +34,7 @@ import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { generateStatisticsChartData } from "./statistics-chart-data";
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -391,6 +392,11 @@ export class StatisticsChart extends LitElement {
}
}
const yAxisScale =
this.chartType.startsWith("line") ||
this.logarithmicScale ||
minYAxis !== undefined ||
maxYAxis !== undefined;
this._chartOptions = {
xAxis: [
{
@@ -434,13 +440,17 @@ export class StatisticsChart extends LitElement {
)
? "right"
: "left",
scale:
this.chartType.startsWith("line") ||
this.logarithmicScale ||
minYAxis !== undefined ||
maxYAxis !== undefined,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
scale: yAxisScale,
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
onFractionDigits: (digits) => {
this._yAxisFractionDigits = digits;
},
}),
splitLine: {
show: true,
},
+61 -4
View File
@@ -1,9 +1,66 @@
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We estimate the tick interval as `range / 10` (twice
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
// intervals), then derive `ceil(-log10(interval))`.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
if (!Number.isFinite(range) || range <= 0) return 1;
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
min: number;
max: number;
}
type YAxisBound =
number | ((values: YAxisExtentValues) => number | undefined) | undefined;
const resolveYAxisBound = (
bound: YAxisBound,
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
// rendered ticks span from 0 even when the data does not. Union the extent
// with 0 to match the labels ECharts actually draws.
includeZero?: boolean;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
} {
const { min, max, includeZero, onFractionDigits } = options;
return {
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
let extentMin = resolvedMin ?? values.min;
let extentMax = resolvedMax ?? values.max;
if (includeZero) {
extentMin = Math.min(extentMin, 0);
extentMax = Math.max(extentMax, 0);
}
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
return resolvedMin;
},
max: (values) => resolveYAxisBound(max, values),
};
}
-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 },
};
}
}
@@ -36,6 +36,7 @@ import { formatTime } from "../../../../../common/datetime/format_time";
import type { HaECOption } from "../../../../../resources/echarts/echarts";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
import { createYAxisPrecisionBounds } from "../../../../../components/chart/y-axis-fraction-digits";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import { getSuggestedPeriod } from "../../../../../data/energy";
@@ -95,13 +96,15 @@ export function getSuggestedMax(
function createYAxisLabelFormatter(
locale: FrontendLocaleData,
fractionDigits: number
getFractionDigits: () => number
) {
return (value: number): string =>
formatNumber(value, locale, {
return (value: number): string => {
const fractionDigits = getFractionDigits();
return formatNumber(value, locale, {
minimumFractionDigits: value === 0 ? 0 : fractionDigits,
maximumFractionDigits: fractionDigits,
});
};
}
export function getCommonOptions(
@@ -123,6 +126,11 @@ export function getCommonOptions(
const showCompareYear =
compare && start.getFullYear() !== compareStart.getFullYear();
// Recompute the tick-label precision from the visible axis extent so labels
// stay distinct when zooming in on a narrow range. Energy axes are anchored
// at 0, so the extent is unioned with 0 to match the rendered ticks.
let currentFractionDigits = yAxisFractionDigits;
// Extend suggestedMax so compare bars that land past the main end
// (e.g. Feb compared to Jan) stay visible instead of being clipped.
if (compare) {
@@ -168,8 +176,17 @@ export function getCommonOptions(
nameTextStyle: {
align: "left",
},
...createYAxisPrecisionBounds({
includeZero: true,
onFractionDigits: (digits) => {
currentFractionDigits = digits;
},
}),
axisLabel: {
formatter: createYAxisLabelFormatter(locale, yAxisFractionDigits),
formatter: createYAxisLabelFormatter(
locale,
() => currentFractionDigits
),
},
splitLine: {
show: true,
-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?",
@@ -947,7 +947,7 @@ exports[`generateStateHistoryChartLineData > matches snapshot for a climate enti
"climate.thermostat",
],
"visualMap": undefined,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -153,7 +153,7 @@ exports[`generateStatisticsChartData > appends current state for recent data 1`]
"sensor.temp_indoor",
],
"unit": "°C",
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { computeYAxisFractionDigits } from "../../../src/components/chart/y-axis-fraction-digits";
import { describe, expect, it, vi } from "vitest";
import {
computeYAxisFractionDigits,
createYAxisPrecisionBounds,
} from "../../../src/components/chart/y-axis-fraction-digits";
describe("computeYAxisFractionDigits", () => {
it("uses two decimals for a sub-unit range (e.g. gas prices around 1.85-2.00)", () => {
@@ -22,8 +25,18 @@ describe("computeYAxisFractionDigits", () => {
});
it("uses more decimals as the range shrinks", () => {
expect(computeYAxisFractionDigits(0, 0.05)).toBe(3);
expect(computeYAxisFractionDigits(0, 0.005)).toBe(4);
// Values match the decimals ECharts actually renders for these ranges
// (tick interval 0.01 -> 2 decimals, 0.001 -> 3 decimals).
expect(computeYAxisFractionDigits(0, 0.05)).toBe(2);
expect(computeYAxisFractionDigits(0, 0.005)).toBe(3);
});
it("matches the tick interval without over-padding on a narrow range", () => {
// A zoomed-in range that steps by 0.01 needs 2 decimals, not 3.
expect(computeYAxisFractionDigits(21.02, 21.08)).toBe(2);
// Ranges whose nice interval carries to a coarser power of ten stay tight.
expect(computeYAxisFractionDigits(15, 15.004)).toBe(3);
expect(computeYAxisFractionDigits(0, 0.04)).toBe(2);
});
it("falls back to one decimal when min equals max", () => {
@@ -36,7 +49,67 @@ describe("computeYAxisFractionDigits", () => {
});
it("handles negative-to-positive ranges by the magnitude of the range", () => {
expect(computeYAxisFractionDigits(-2, 2)).toBe(1);
expect(computeYAxisFractionDigits(-2, 2)).toBe(0);
expect(computeYAxisFractionDigits(-0.1, 0.1)).toBe(2);
});
});
describe("createYAxisPrecisionBounds", () => {
it("computes digits from the visible extent when no bounds are set", () => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
expect(min({ min: 0, max: 100 })).toBeUndefined();
expect(max({ min: 0, max: 100 })).toBeUndefined();
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
min({ min: 21.02, max: 21.08 });
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
});
it("computes digits from numeric bounds and returns them unchanged", () => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: 1.85,
max: 2,
onFractionDigits,
});
// Fixed bounds pin the range, so the visible extent is ignored
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
expect(max({ min: 1.9, max: 1.95 })).toBe(2);
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
});
it("resolves function bounds and passes their result through", () => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: ({ min: dataMin }) => dataMin - 1,
max: ({ max: dataMax }) => dataMax + 1,
onFractionDigits,
});
expect(min({ min: 10, max: 11 })).toBe(9);
expect(max({ min: 10, max: 11 })).toBe(12);
// Range widened to 9..12 -> single decimal
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
});
it("unions the extent with zero for anchored axes", () => {
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({
includeZero: true,
onFractionDigits,
});
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
min({ min: 20, max: 25 });
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// Small visible max close to zero -> more decimals
min({ min: 0.02, max: 0.05 });
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
});
});
@@ -14696,7 +14696,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for 5minute per
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -16102,7 +16102,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for a small hou
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -16649,7 +16649,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for daily perio
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -18218,7 +18218,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot with child devi
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -21922,7 +21922,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot with compare da
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -23083,6 +23083,6 @@ exports[`generateEnergyDevicesDetailGraphData > respects max_devices config 1`]
},
],
"start": 2024-01-01T00:00:00.000Z,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;
@@ -6434,7 +6434,7 @@ exports[`generateEnergySolarGraphData > matches snapshot with hourly forecast da
"end": 2024-01-03T00:00:00.000Z,
"start": 2024-01-01T00:00:00.000Z,
"total": 45.402,
"yAxisFractionDigits": 1,
"yAxisFractionDigits": 0,
}
`;