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
9 changed files with 205 additions and 33 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),
};
}
@@ -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,
@@ -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,
}
`;