This commit is contained in:
Mateusz Gruszczyński
2026-09-14 23:10:17 +02:00
parent 4bb9c8621a
commit 0a2fb8c6c7
47 changed files with 1337 additions and 372 deletions
+81 -30
View File
@@ -25,6 +25,32 @@ function historyEntityOptions() {
return { zones, devices, sensors, entities };
}
function energyHistoryTargets() {
const configuredGroups = (app.deviceGroups || []).filter(group => !!group.energy_device_id || !!group.ha_energy_entity_id);
const groups = configuredGroups.map(group => ({
id: `group:${group.id}`,
label: `${group.name} · ${deviceInstallationKindLabel(group)}`,
type: 'group',
group,
}));
const groupedIds = new Set(configuredGroups.flatMap(group => group.device_ids || []));
const devices = app.devices
.filter(device => (device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id) && !groupedIds.has(device.id))
.map(device => ({ id: device.id, label: `${device.name} · ${deviceTransportLabel(device)}`, type: 'device', device }));
return [...groups, ...devices];
}
function normalizeEnergyHistoryTargets() {
const available = energyHistoryTargets();
const ids = new Set(available.map(item => item.id));
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id];
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
return available;
}
function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const host = $('#historyContextControls'); if (!host) return;
@@ -36,14 +62,15 @@ function renderHistoryNavigation() {
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') {
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
if (!app.historyEnergyDevice && energyDevices.length) app.historyEnergyDevice = energyDevices[0].id;
const deviceOptions = energyDevices.map(device => `<option value="${esc(device.id)}">${esc(device.name)} · ${esc(deviceTransportLabel(device))}</option>`).join('');
host.innerHTML = energyDevices.length
? `<label><span>${esc(tr('common.device'))}</span><select id="historyEnergyDeviceSelect">${deviceOptions}</select></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label>`
const targets = normalizeEnergyHistoryTargets();
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).join('');
host.innerHTML = targets.length
? `<label class="history-energy-targets"><span>${esc(tr('energy.targets'))}</span><select id="historyEnergyTargetSelect" multiple size="${Math.min(6, Math.max(2, targets.length))}">${targetOptions}</select><small>${esc(tr('energy.multiselectHint'))}</small></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
const deviceSelect = $('#historyEnergyDeviceSelect'); if (deviceSelect && [...deviceSelect.options].some(option => option.value === app.historyEnergyDevice)) deviceSelect.value = app.historyEnergyDevice;
const targetSelect = $('#historyEnergyTargetSelect');
if (targetSelect) [...targetSelect.options].forEach(option => { option.selected = app.historyEnergyTargets.includes(option.value); });
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
} else if (app.historyTab === 'sensors') {
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
@@ -55,7 +82,7 @@ function renderHistoryNavigation() {
}
async function loadHistory() {
if (app.historyLoading) return;
if (app.historyLoading) { app.historyReloadPending = true; return; }
app.historyLoading = true;
const hours = $('#historyHours')?.value || '24';
try {
@@ -75,7 +102,10 @@ async function loadHistory() {
} catch (error) {
toast(error.message, true);
renderHistoryPage();
} finally { app.historyLoading = false; }
} finally {
app.historyLoading = false;
if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); }
}
}
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
@@ -116,7 +146,7 @@ function redrawHistoryChart(id) {
if (!runtime || !canvas) return;
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
if (runtime.kind === 'energy') {
drawEnergyBarChart(canvas, runtime.buckets, runtime.options);
drawEnergyBarChart(canvas, runtime.series, runtime.options);
} else {
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
@@ -438,24 +468,42 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
}
function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
function energyBucketLabel(timestamp, interval) {
const date = new Date(timestamp);
if (interval === 'hourly') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date);
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'short', year: '2-digit' }).format(date);
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
}
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
if (!canvas) return;
const options = { height };
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', buckets, options });
if (!buckets?.length) return drawEmptyChart(canvas, height);
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
? seriesInput
: [{ key: 'energy', label: 'kWh', color: historySeriesColor(0), buckets: Array.isArray(seriesInput) ? seriesInput : [] }];
const options = { height, interval };
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', series, options });
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
const starts = [...new Set(visibleSeries.flatMap(item => (item.buckets || []).map(row => row.start)))].sort((a, b) => new Date(a) - new Date(b));
if (!starts.length || !visibleSeries.length) {
drawEmptyChart(canvas, height);
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
return;
}
const maps = visibleSeries.map(item => new Map((item.buckets || []).map(row => [row.start, Math.max(0, Number(row.consumption_kwh) || 0)])));
const values = maps.flatMap(map => starts.map(start => map.get(start) || 0));
const prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.height;
const text = cssColor('--muted', '#888');
const grid = cssColor('--grid', '#333');
const fill = cssColor('--accent', '#3ecf8e');
const pad = { left: 58, right: 18, top: 20, bottom: 48 };
const values = buckets.map(row => Math.max(0, Number(row.consumption_kwh) || 0));
const max = Math.max(0.1, ...values) * 1.1;
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const slot = plotW / Math.max(1, buckets.length);
const barW = Math.max(2, Math.min(slot * 0.72, 42));
const slot = plotW / Math.max(1, starts.length);
const groupW = Math.max(4, Math.min(slot * 0.78, 56));
const barW = Math.max(2, groupW / Math.max(1, visibleSeries.length));
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const value = max * i / 5;
@@ -463,25 +511,28 @@ function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(value < 1 ? 2 : 1)}`, pad.left - 8, y + 3);
}
ctx.fillStyle = fill;
buckets.forEach((row, index) => {
const value = values[index];
const x = pad.left + slot * index + (slot - barW) / 2;
const barH = (value / max) * plotH;
ctx.fillRect(x, pad.top + plotH - barH, barW, barH);
starts.forEach((start, bucketIndex) => {
const baseX = pad.left + slot * bucketIndex + (slot - groupW) / 2;
visibleSeries.forEach((item, seriesIndex) => {
const value = maps[seriesIndex].get(start) || 0;
const barH = (value / max) * plotH;
ctx.save();
ctx.globalAlpha = item.comparison ? 0.45 : 0.92;
ctx.fillStyle = item.color || historySeriesColor(seriesIndex);
ctx.fillRect(baseX + seriesIndex * barW, pad.top + plotH - barH, Math.max(1, barW - 1), barH);
ctx.restore();
});
});
ctx.fillStyle = text;
const ticks = Math.min(6, buckets.length);
const ticks = Math.min(6, starts.length);
for (let i = 0; i < ticks; i++) {
const index = ticks === 1 ? 0 : Math.round(i * (buckets.length - 1) / (ticks - 1));
const index = ticks === 1 ? 0 : Math.round(i * (starts.length - 1) / (ticks - 1));
const x = pad.left + slot * index + slot / 2;
ctx.textAlign = 'center';
ctx.fillText(timeLabel(buckets[index].start, $('#historyHours')?.value), x, height - 18);
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
}
ctx.textAlign = 'left';
ctx.fillText('kWh', 8, pad.top + 4);
const legend = document.getElementById(`${canvas.id}Legend`);
if (legend) legend.innerHTML = `<span class="legend-item"><i class="legend-line" style="--legend-color:${esc(fill)}"></i><span>kWh</span></span>`;
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
}