mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-03 13:42:17 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a2394df5 |
@@ -1,16 +1,8 @@
|
||||
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,
|
||||
extraBuckets?: number[]
|
||||
stacked = true
|
||||
) {
|
||||
if (!stacked) {
|
||||
// For non-stacked charts, we can simply apply an overall border to each stack
|
||||
@@ -52,7 +44,6 @@ export function fillDataGapsAndRoundCaps(
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
.concat(extraBuckets ?? [])
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
@@ -70,18 +61,9 @@ export function fillDataGapsAndRoundCaps(
|
||||
const x = item.value?.[0];
|
||||
const stack = datasets[i].stack ?? "";
|
||||
if (x === undefined) {
|
||||
// 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;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (x === undefined || Number(x) !== bucket) {
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
startOfMonth,
|
||||
addYears,
|
||||
addMonths,
|
||||
addMinutes,
|
||||
addHours,
|
||||
startOfDay,
|
||||
addDays,
|
||||
subDays,
|
||||
} from "date-fns";
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
CallbackDataParams,
|
||||
LineSeriesOption,
|
||||
TopLevelFormatterParams,
|
||||
@@ -38,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";
|
||||
|
||||
@@ -97,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(
|
||||
@@ -125,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) {
|
||||
@@ -170,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,
|
||||
@@ -379,97 +394,12 @@ 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.
|
||||
*
|
||||
* `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.
|
||||
* 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.)
|
||||
*/
|
||||
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 function getPeriodMidpointOffset(period: string): number {
|
||||
return (PERIOD_MS[period] ?? 0) / 2;
|
||||
}
|
||||
|
||||
export interface UntrackedSplit {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
computeStatMidpoint,
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
splitUntrackedConsumption,
|
||||
@@ -239,15 +238,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 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
|
||||
);
|
||||
// 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);
|
||||
sortedTimes.forEach((time) => {
|
||||
const ts = Number(time);
|
||||
const x = compare
|
||||
@@ -516,11 +515,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
}
|
||||
}
|
||||
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
|
||||
);
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { getEnergyColor } from "./common/color";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
} from "./common/energy-chart-options";
|
||||
|
||||
@@ -114,11 +113,7 @@ export function generateEnergyGasGraphData(
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, period)
|
||||
);
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const chartData = datasets;
|
||||
const total = processTotal(energyData.stats, gasSources);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getEnergyColor } from "./common/color";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
@@ -118,11 +117,7 @@ export function generateEnergySolarGraphData(
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets as BarSeriesOption[],
|
||||
true,
|
||||
generateFillBuckets(datasets as BarSeriesOption[], start, end, period)
|
||||
);
|
||||
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
|
||||
|
||||
if (forecasts) {
|
||||
datasets.push(
|
||||
@@ -327,18 +322,20 @@ function processForecast(
|
||||
|
||||
if (forecastsData) {
|
||||
const solarForecastData: LineSeriesOption["data"] = [];
|
||||
// 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
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
for (const [time, value] of Object.entries(forecastsData)) {
|
||||
const kWh = value / 1000;
|
||||
solarForecastData.push([Number(time) + forecastOffset, kWh]);
|
||||
|
||||
@@ -37,7 +37,6 @@ import { hasConfigChanged } from "../../common/has-changed";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
@@ -451,16 +450,7 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
// @ts-expect-error
|
||||
datasets.sort((a, b) => a.order - b.order);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(
|
||||
datasets,
|
||||
this._start,
|
||||
this._end,
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._legendData = this._getLegendData(datasets);
|
||||
@@ -606,14 +596,15 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const period = getSuggestedPeriod(this._start, this._end);
|
||||
const periodOffset = getPeriodMidpointOffset(
|
||||
period,
|
||||
uniqueKeys.length >= 2 ? uniqueKeys[1] - uniqueKeys[0] : undefined
|
||||
);
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
|
||||
? (uniqueKeys[1] - uniqueKeys[0]) / 2
|
||||
: getPeriodMidpointOffset(period);
|
||||
|
||||
const compareTransform = getCompareTransform(
|
||||
this._start,
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
computeStatMidpoint,
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
} from "./common/energy-chart-options";
|
||||
@@ -264,16 +263,7 @@ export class HuiEnergyWaterGraphCard
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(
|
||||
datasets,
|
||||
this._start,
|
||||
this._end,
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._total = this._processTotal(energyData.stats, waterSources);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-875
@@ -25,8 +25,8 @@ exports[`generateEnergyGasGraphData > large 5-minute payload digest is stable (c
|
||||
"unit",
|
||||
"yAxisFractionDigits",
|
||||
],
|
||||
"numberCount": 392840,
|
||||
"numberSum": "2.71986228397e+17",
|
||||
"numberCount": 312488,
|
||||
"numberSum": "2.26308627397e+17",
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
@@ -52,15 +52,6 @@ exports[`generateEnergyGasGraphData > matches snapshot for a single gas source (
|
||||
"color": "#1b7ea07F",
|
||||
"cursor": "default",
|
||||
"data": [
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderWidth": 0,
|
||||
},
|
||||
"value": [
|
||||
1704069000000,
|
||||
0,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderRadius": [
|
||||
@@ -3385,438 +3376,6 @@ 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": {
|
||||
@@ -4551,438 +4110,6 @@ 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": {
|
||||
|
||||
+8
-1106
File diff suppressed because it is too large
Load Diff
+3
-894
@@ -11,8 +11,8 @@ exports[`generateEnergySolarGraphData > large 5-minute payload digest is stable
|
||||
"total",
|
||||
"yAxisFractionDigits",
|
||||
],
|
||||
"numberCount": 285622,
|
||||
"numberSum": "1.81353699874e+17",
|
||||
"numberCount": 232066,
|
||||
"numberSum": "1.50908786888e+17",
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
||||
@@ -1008,15 +1008,6 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
|
||||
1705190400000,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderWidth": 0,
|
||||
},
|
||||
"value": [
|
||||
1705276800000,
|
||||
0,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderRadius": [
|
||||
@@ -1272,15 +1263,6 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
|
||||
1706745600000,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderWidth": 0,
|
||||
},
|
||||
"value": [
|
||||
1706832000000,
|
||||
0,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderRadius": [
|
||||
@@ -1657,438 +1639,6 @@ 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": {
|
||||
@@ -2823,438 +2373,6 @@ 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": {
|
||||
@@ -6476,15 +5594,6 @@ exports[`generateEnergySolarGraphData > matches snapshot with hourly forecast da
|
||||
1704085200000,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderWidth": 0,
|
||||
},
|
||||
"value": [
|
||||
1704090600000,
|
||||
0,
|
||||
],
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"borderRadius": [
|
||||
@@ -7325,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,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
computeStatMidpoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
fillLineGaps,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
getSuggestedMax,
|
||||
splitUntrackedConsumption,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
|
||||
@@ -500,307 +498,6 @@ 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,7 +4,6 @@
|
||||
* 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";
|
||||
@@ -210,156 +209,4 @@ 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,26 +164,27 @@ describe("generateEnergyDevicesDetailGraphData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// 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, {
|
||||
// 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, {
|
||||
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, rows]) => [
|
||||
id,
|
||||
rows.filter((row) => row.start === firstStart),
|
||||
])
|
||||
Object.entries(full.stats).map(
|
||||
([id, values]) =>
|
||||
[id, values.filter((s) => s.start === firstStart)] as const
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
@@ -192,29 +193,26 @@ describe("generateEnergyDevicesDetailGraphData", () => {
|
||||
energyData,
|
||||
});
|
||||
|
||||
const nonZeroXs = new Set<number>();
|
||||
// Collect the display x of every bar across all series.
|
||||
const xs = new Set<number>();
|
||||
let nonEmptySeries = 0;
|
||||
for (const series of result.chartData) {
|
||||
if (!series.data?.length) {
|
||||
continue;
|
||||
const points = series.data ?? [];
|
||||
if (points.length) {
|
||||
nonEmptySeries++;
|
||||
}
|
||||
// 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]);
|
||||
for (const point of points as any[]) {
|
||||
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
|
||||
if (x != null) {
|
||||
xs.add(Number(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...and all real values stack on the single bucket midpoint.
|
||||
assert.deepEqual([...nonZeroXs], [firstStart + HOUR / 2]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// The seeded fixtures above all happen to produce fully-negative untracked
|
||||
|
||||
@@ -209,57 +209,4 @@ 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user