mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-03 21:52:12 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a8194a72 |
@@ -16,7 +16,6 @@ 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 {
|
||||
@@ -29,10 +28,6 @@ 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.
|
||||
@@ -106,7 +101,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
|
||||
@state() private _yWidth = 25;
|
||||
|
||||
@state() private _visualMap?: VisualMapComponentOption[];
|
||||
|
||||
@@ -323,18 +318,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
yAxis: {
|
||||
type: this.logarithmicScale ? "log" : "value",
|
||||
name: this.unit,
|
||||
...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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
position: rtl ? "right" : "left",
|
||||
scale: true,
|
||||
nameGap: 2,
|
||||
@@ -463,7 +448,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
});
|
||||
const width = Math.max(measureTextWidth(label, 12) + 5, MIN_Y_AXIS_WIDTH);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
|
||||
@@ -34,7 +34,6 @@ 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",
|
||||
@@ -392,11 +391,6 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const yAxisScale =
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: [
|
||||
{
|
||||
@@ -440,17 +434,13 @@ export class StatisticsChart extends LitElement {
|
||||
)
|
||||
? "right"
|
||||
: "left",
|
||||
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;
|
||||
},
|
||||
}),
|
||||
scale:
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -1,66 +1,9 @@
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// 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.
|
||||
// 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))`.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
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),
|
||||
};
|
||||
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
|
||||
}
|
||||
|
||||
@@ -864,7 +864,10 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Baseline-align so a wrapped multi-line value keeps the subject and
|
||||
the trailing time on its first line, while a single-line entry
|
||||
stays aligned with the text. */
|
||||
align-items: baseline;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
@@ -876,9 +879,9 @@ class HaLogbookEntry extends LitElement {
|
||||
.primary-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Wrap long entries onto multiple lines instead of truncating them to
|
||||
a single line with an ellipsis. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.primary > .subject {
|
||||
@@ -903,13 +906,12 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
.value {
|
||||
/* Don't shrink: the subject absorbs all truncation so a short state
|
||||
stays whole. max-width still caps a long one. */
|
||||
/* Don't shrink: the subject absorbs truncation so a short state stays
|
||||
whole. A long value wraps within its max-width instead of being cut
|
||||
off. */
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ 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";
|
||||
|
||||
@@ -96,15 +95,13 @@ export function getSuggestedMax(
|
||||
|
||||
function createYAxisLabelFormatter(
|
||||
locale: FrontendLocaleData,
|
||||
getFractionDigits: () => number
|
||||
fractionDigits: number
|
||||
) {
|
||||
return (value: number): string => {
|
||||
const fractionDigits = getFractionDigits();
|
||||
return formatNumber(value, locale, {
|
||||
return (value: number): string =>
|
||||
formatNumber(value, locale, {
|
||||
minimumFractionDigits: value === 0 ? 0 : fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommonOptions(
|
||||
@@ -126,11 +123,6 @@ 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) {
|
||||
@@ -176,17 +168,8 @@ export function getCommonOptions(
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
...createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits: (digits) => {
|
||||
currentFractionDigits = digits;
|
||||
},
|
||||
}),
|
||||
axisLabel: {
|
||||
formatter: createYAxisLabelFormatter(
|
||||
locale,
|
||||
() => currentFractionDigits
|
||||
),
|
||||
formatter: createYAxisLabelFormatter(locale, yAxisFractionDigits),
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
|
||||
@@ -947,7 +947,7 @@ exports[`generateStateHistoryChartLineData > matches snapshot for a climate enti
|
||||
"climate.thermostat",
|
||||
],
|
||||
"visualMap": undefined,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ exports[`generateStatisticsChartData > appends current state for recent data 1`]
|
||||
"sensor.temp_indoor",
|
||||
],
|
||||
"unit": "°C",
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeYAxisFractionDigits,
|
||||
createYAxisPrecisionBounds,
|
||||
} from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeYAxisFractionDigits } 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)", () => {
|
||||
@@ -25,18 +22,8 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
it("uses more decimals as the range shrinks", () => {
|
||||
// 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);
|
||||
expect(computeYAxisFractionDigits(0, 0.05)).toBe(3);
|
||||
expect(computeYAxisFractionDigits(0, 0.005)).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to one decimal when min equals max", () => {
|
||||
@@ -49,67 +36,7 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
it("handles negative-to-positive ranges by the magnitude of the range", () => {
|
||||
expect(computeYAxisFractionDigits(-2, 2)).toBe(0);
|
||||
expect(computeYAxisFractionDigits(-2, 2)).toBe(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-6
@@ -14696,7 +14696,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for 5minute per
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -16102,7 +16102,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for a small hou
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -16649,7 +16649,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot for daily perio
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -18218,7 +18218,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot with child devi
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -21922,7 +21922,7 @@ exports[`generateEnergyDevicesDetailGraphData > matches snapshot with compare da
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -23083,6 +23083,6 @@ exports[`generateEnergyDevicesDetailGraphData > respects max_devices config 1`]
|
||||
},
|
||||
],
|
||||
"start": 2024-01-01T00:00:00.000Z,
|
||||
"yAxisFractionDigits": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
+1
-1
@@ -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": 0,
|
||||
"yAxisFractionDigits": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user