This commit is contained in:
Mateusz Gruszczyński
2026-09-15 09:04:29 +02:00
parent 0a2fb8c6c7
commit 2ff310bdc5
37 changed files with 731 additions and 141 deletions
+97 -4
View File
@@ -51,9 +51,45 @@ function normalizeEnergyHistoryTargets() {
return available;
}
function energyTargetPickerSummary(targets) {
const selected = targets.filter(target => app.historyEnergyTargets.includes(target.id));
if (!selected.length) return tr('energy.chooseTargets');
if (selected.length === 1) return selected[0].label;
return tr('energy.selectedCount', { count: selected.length });
}
function resetHistoryRangeControl(host) {
const range = $('#historyRangeControl');
const toolbar = host?.closest('.chart-toolbar');
const refresh = $('#historyRefresh');
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
}
function configureHistoryRangeControl(tab, host) {
const range = $('#historyRangeControl');
if (!range) return;
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar');
const label = range.querySelector('span');
const hint = $('#historyRangeHint');
const sixHours = range.querySelector('option[value="6"]');
const energy = tab === 'energy';
const rangeLabel = tr(energy ? 'energy.period' : 'history.range');
if (label) label.textContent = rangeLabel;
range.setAttribute('aria-label', rangeLabel);
if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; }
if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; }
if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24';
range.classList.toggle('energy-period-control', energy);
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
if (energy && host) host.appendChild(range);
}
function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true;
const host = $('#historyContextControls'); if (!host) return;
resetHistoryRangeControl(host);
host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
const options = historyEntityOptions();
if (app.historyTab === 'zones') {
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
@@ -63,14 +99,13 @@ function renderHistoryNavigation() {
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') {
const targets = normalizeEnergyHistoryTargets();
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).join('');
const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).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>`
? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><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 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;
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
} 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;
@@ -79,6 +114,7 @@ function renderHistoryNavigation() {
} else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
}
configureHistoryRangeControl(app.historyTab, host);
}
async function loadHistory() {
@@ -475,6 +511,62 @@ function energyBucketLabel(timestamp, interval) {
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
}
function energyBucketTooltipLabel(timestamp, interval) {
const date = new Date(timestamp);
if (interval === 'hourly') return chartPreciseTime(timestamp);
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'long', year: 'numeric' }).format(date);
if (interval === 'weekly') return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
}
function bindEnergyChartTooltip(canvas, series, starts, maps, geometry) {
const wrap = canvas?.parentElement;
const tooltip = wrap?.querySelector('.chart-tooltip');
const line = wrap?.querySelector('.chart-hover-line');
if (!canvas || !wrap || !tooltip || !line || !starts.length) return;
const { pad, width, height, slot, interval } = geometry;
let activeIndex = -1;
const hide = () => { tooltip.hidden = true; line.hidden = true; activeIndex = -1; };
const showIndex = index => {
index = clamp(index, 0, starts.length - 1);
const start = starts[index];
const px = pad.left + slot * index + slot / 2;
const rows = series.map((item, seriesIndex) => ({ item, value: maps[seriesIndex].get(start) || 0 }));
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(energyBucketTooltipLabel(start, interval))}</strong><div class="chart-tooltip-values">${rows.map(({ item, value }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh</span></div>`).join('')}</div>`;
tooltip.hidden = false;
line.hidden = false;
line.style.left = `${px}px`;
line.style.top = `${pad.top}px`;
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
const minLeft = wrap.scrollLeft + 8;
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
let left = px + 12;
if (left + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) left = px - tooltip.offsetWidth - 12;
tooltip.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
tooltip.style.top = `${pad.top + 8}px`;
activeIndex = index;
};
const indexFromClientX = clientX => {
const rect = canvas.getBoundingClientRect();
const x = clamp(clientX - rect.left, pad.left, width - pad.right - 1);
return clamp(Math.floor((x - pad.left) / Math.max(1, slot)), 0, starts.length - 1);
};
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showIndex(indexFromClientX(event.clientX)); };
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null;
canvas.onkeydown = event => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
if (activeIndex < 0) activeIndex = event.key === 'ArrowRight' ? 0 : starts.length - 1;
else activeIndex = clamp(activeIndex + (event.key === 'ArrowRight' ? 1 : -1), 0, starts.length - 1);
showIndex(activeIndex);
};
}
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
if (!canvas) return;
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
@@ -532,6 +624,7 @@ function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
}
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
bindEnergyChartTooltip(canvas, visibleSeries, starts, maps, { pad, width, height, slot, interval });
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
}