Compare commits

...

7 Commits

Author SHA1 Message Date
Petar Petrov
ec43671550 Only center bars for sub-daily periods using getSuggestedPeriod 2026-03-31 12:50:23 +03:00
Petar Petrov
3363347200 Use numbers and EnergyDataPoint in usage card, add offset comments 2026-03-31 11:54:53 +03:00
Petar Petrov
4ab42d7325 Add EnergyDataPoint type for chart data point tuples 2026-03-31 11:45:39 +03:00
Petar Petrov
37b83af087 Extract computeStatMidpoint helper to deduplicate compare midpoint logic 2026-03-31 11:41:56 +03:00
Petar Petrov
ed3cf9918b Align solar forecast line points with bar midpoints 2026-03-31 11:14:19 +03:00
Petar Petrov
d670467bc1 Fix compare mode bar alignment and revert forecast line shift
In compare mode, transform start/end boundaries separately before
computing the midpoint. This ensures compare bars land at the exact
same x-positions as main bars, even for variable-length periods like
months. Revert the solar forecast line shift since lines don't need
centering.
2026-03-31 10:12:49 +03:00
Petar Petrov
a4d9e0c7f5 Center energy dashboard bar charts on period midpoint 2026-03-25 16:30:36 +02:00
6 changed files with 146 additions and 48 deletions

View File

@@ -37,6 +37,18 @@ import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getSuggestedPeriod } from "../../../../../data/energy";
/**
* Energy chart data point tuple:
* [0] displayX - bar position (midpoint for sub-daily periods, start otherwise)
* [1] value - the energy value
* [2] originalStart - original period start timestamp, used for tooltips
*/
export type EnergyDataPoint = [
displayX: number,
value: number,
originalStart: number,
];
// Number of days of padding when showing time axis in months
const MONTH_TIME_AXIS_PADDING = 5;
@@ -216,9 +228,8 @@ function formatTooltip(
if (!params[0]?.value) {
return "";
}
// when comparing the first value is offset to match the main period
// and the real date is in the third value
// find the first param with the real date to handle gap-filled entries
// displayX may be shifted from the period start (see EnergyDataPoint);
// originalStart has the real date for display. Gap-filled entries lack it.
const origDate = params.find((p) => p.value?.[2] != null)?.value?.[2];
const date = new Date(origDate ?? params[0].value?.[0]);
let period: string;
@@ -382,6 +393,34 @@ export function fillLineGaps(datasets: LineSeriesOption[]) {
return datasets;
}
/**
* Compute the display x-position for an energy bar chart data point.
* For sub-daily periods (hour/5minute), returns the midpoint to center bars
* between ticks. For daily or longer periods, returns the start timestamp.
*/
export function computeStatMidpoint(
start: number,
end: number,
period: string,
compareTransform?: (ts: Date) => Date
): number {
const center = period === "hour" || period === "5minute";
if (!center) {
if (compareTransform) {
return compareTransform(new Date(start)).getTime();
}
return start;
}
if (compareTransform) {
return (
(compareTransform(new Date(start)).getTime() +
compareTransform(new Date(end)).getTime()) /
2
);
}
return (start + end) / 2;
}
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;

View File

@@ -16,6 +16,7 @@ import type {
} from "../../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
getSummedData,
computeConsumptionData,
validateEnergyCollectionKey,
@@ -32,6 +33,8 @@ import type { LovelaceCard } from "../../types";
import type { EnergyDevicesDetailGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
@@ -389,31 +392,36 @@ export class HuiEnergyDevicesDetailGraphCard
processedData.forEach((device) => {
device.data.forEach((datapoint) => {
totalDeviceConsumption[datapoint[compare ? 2 : 0]] =
(totalDeviceConsumption[datapoint[compare ? 2 : 0]] || 0) +
datapoint[1];
totalDeviceConsumption[datapoint[2]] =
(totalDeviceConsumption[datapoint[2]] || 0) + datapoint[1];
});
});
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.used_total)
.sort((a, b) => Number(a) - Number(b))
.forEach((time) => {
const ts = Number(time);
const value =
consumptionData.used_total[time] -
(totalDeviceConsumption[time] || 0);
const dataPoint: number[] = [ts, value];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(ts)).getTime();
}
untrackedConsumption.push(dataPoint);
});
const sortedTimes = Object.keys(consumptionData.used_total).sort(
(a, b) => Number(a) - Number(b)
);
// Only start timestamps available here, so estimate midpoint from the gap
// between the first two entries. Assumes uniform period spacing.
const periodOffset =
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
: 0;
sortedTimes.forEach((time) => {
const ts = Number(time);
const value =
consumptionData.used_total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: EnergyDataPoint = [ts + periodOffset, value, ts];
if (compare) {
dataPoint[0] = compareTransform(new Date(ts)).getTime() + periodOffset;
}
untrackedConsumption.push(dataPoint);
});
// random id to always add untracked at the end
const order = Date.now();
const dataset: BarSeriesOption = {
@@ -460,6 +468,7 @@ export class HuiEnergyDevicesDetailGraphCard
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
devices.forEach((source, idx) => {
const order = sorted_devices.indexOf(source.stat_consumption);
@@ -499,11 +508,16 @@ export class HuiEnergyDevicesDetailGraphCard
cStats?.find((cStat) => cStat.start === point.start)?.change || 0;
});
const dataPoint = [point.start, point.change - sumChildren];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start)).getTime();
}
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change - sumChildren,
point.start,
];
consumptionData.push(dataPoint);
prevStart = point.start;
}

View File

@@ -16,6 +16,7 @@ import type {
} from "../../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
@@ -27,6 +28,8 @@ import type { LovelaceCard } from "../../types";
import type { EnergyGasGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
@@ -265,6 +268,7 @@ export class HuiEnergyGasGraphCard
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -285,14 +289,16 @@ export class HuiEnergyGasGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
}
gasConsumptionData.push(dataPoint);
prevStart = point.start;
}

View File

@@ -30,6 +30,8 @@ import type { LovelaceCard } from "../../types";
import type { EnergySolarGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
@@ -287,6 +289,7 @@ export class HuiEnergySolarGraphCard
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -308,14 +311,16 @@ export class HuiEnergySolarGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
}
solarProductionData.push(dataPoint);
prevStart = point.start;
}
@@ -410,8 +415,24 @@ export class HuiEnergySolarGraphCard
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
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
: 0;
}
for (const [time, value] of Object.entries(forecastsData)) {
solarForecastData.push([Number(time), value / 1000]);
solarForecastData.push([
Number(time) + forecastOffset,
value / 1000,
]);
}
if (solarForecastData.length) {

View File

@@ -24,6 +24,7 @@ import type {
import {
computeConsumptionData,
getEnergyDataCollection,
getSuggestedPeriod,
getSummedData,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -36,6 +37,7 @@ import type { LovelaceCard } from "../../types";
import type { EnergyUsageGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
@@ -482,6 +484,15 @@ export class HuiEnergyUsageGraphCard
const uniqueKeys = summedData.timestamps;
// Only center bars for sub-daily periods (hour/5min).
// Only start timestamps available here, so estimate midpoint from the gap
// between the first two entries. Assumes uniform period spacing.
const period = getSuggestedPeriod(this._start, this._end);
const periodOffset =
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
? (uniqueKeys[1] - uniqueKeys[0]) / 2
: 0;
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
@@ -493,15 +504,16 @@ export class HuiEnergyUsageGraphCard
// Process chart data.
for (const key of uniqueKeys) {
const value = source[key] || 0;
const dataPoint = [
new Date(key),
const dataPoint: EnergyDataPoint = [
key + periodOffset,
value && ["to_grid", "to_battery"].includes(type)
? -1 * value
: value,
key,
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(dataPoint[0] as Date);
dataPoint[0] =
compareTransform(new Date(key)).getTime() + periodOffset;
}
points.push(dataPoint);
}

View File

@@ -15,6 +15,7 @@ import type {
} from "../../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
@@ -26,6 +27,8 @@ import type { LovelaceCard } from "../../types";
import type { EnergyWaterGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
@@ -265,6 +268,7 @@ export class HuiEnergyWaterGraphCard
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
waterSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -285,14 +289,16 @@ export class HuiEnergyWaterGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
}
waterConsumptionData.push(dataPoint);
prevStart = point.start;
}