This commit is contained in:
Mateusz Gruszczyński
2026-09-16 09:04:43 +02:00
parent 3d69e74319
commit 0437ef7b6f
28 changed files with 1254 additions and 221 deletions
+70 -5
View File
@@ -66,29 +66,36 @@ function resetHistoryRangeControl(host) {
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
}
function configureHistoryRangeControl(tab, host) {
function configureHistoryRangeControl(tab, host, hasEnergyTargets = true) {
const range = $('#historyRangeControl');
if (!range) return;
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar');
const toolbarPanel = toolbar?.closest('.history-toolbar-panel');
const refresh = $('#historyRefresh');
const label = range.querySelector('span');
const hint = $('#historyRangeHint');
const sixHours = range.querySelector('option[value="6"]');
const energy = tab === 'energy';
const energyUnavailable = energy && !hasEnergyTargets;
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.hidden = energyUnavailable;
if (refresh) refresh.hidden = energyUnavailable;
if (toolbarPanel) toolbarPanel.hidden = energyUnavailable;
range.classList.toggle('energy-period-control', energy);
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
if (energy && host) host.appendChild(range);
if (energy && host && hasEnergyTargets) 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;
let hasEnergyTargets = true;
resetHistoryRangeControl(host);
host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
const options = historyEntityOptions();
@@ -100,10 +107,11 @@ 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();
hasEnergyTargets = targets.length > 0;
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
? `<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 intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
@@ -121,7 +129,7 @@ function renderHistoryNavigation() {
} else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
}
configureHistoryRangeControl(app.historyTab, host);
configureHistoryRangeControl(app.historyTab, host, hasEnergyTargets);
}
async function loadHistory() {
@@ -164,6 +172,7 @@ async function loadHistory() {
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
const MAX_CHART_ZOOM = 16;
const chartRuntime = new Map();
const chartFullscreenState = new WeakMap();
function chartSeriesKey(item, index) {
return item.key || `${index}:${item.label}`;
@@ -251,6 +260,28 @@ function updateChartFullscreenButton(card) {
button.setAttribute('aria-label', button.title);
}
function restoreChartPreviewLayout(card) {
const state = chartFullscreenState.get(card);
if (!state) return null;
const canvas = card.querySelector('canvas[id]');
const wrap = canvas?.parentElement;
if (canvas) {
if (state.canvasStyleWidth) canvas.style.width = state.canvasStyleWidth; else canvas.style.removeProperty('width');
if (state.canvasStyleHeight) canvas.style.height = state.canvasStyleHeight; else canvas.style.removeProperty('height');
canvas.width = state.canvasWidth;
canvas.height = state.canvasHeight;
}
if (wrap) {
if (state.wrapStyleHeight) wrap.style.height = state.wrapStyleHeight; else wrap.style.removeProperty('height');
if (state.wrapStyleMinHeight) wrap.style.minHeight = state.wrapStyleMinHeight; else wrap.style.removeProperty('min-height');
if (state.wrapStyleMaxHeight) wrap.style.maxHeight = state.wrapStyleMaxHeight; else wrap.style.removeProperty('max-height');
wrap.scrollLeft = state.scrollLeft;
wrap.scrollTop = state.scrollTop;
}
chartFullscreenState.delete(card);
return state;
}
function closeChartPreview(card) {
if (!card) return;
card.classList.remove('chart-fullscreen-fallback');
@@ -258,9 +289,31 @@ function closeChartPreview(card) {
card.removeAttribute('aria-modal');
card.removeAttribute('aria-label');
document.body.classList.remove('chart-fullscreen-open');
const state = restoreChartPreviewLayout(card);
updateChartFullscreenButton(card);
const canvas = card.querySelector('canvas[id]');
if (canvas?.id) requestAnimationFrame(() => redrawHistoryChart(canvas.id));
if (!canvas?.id) return;
// Force layout after dropping the fullscreen class before sizing the canvas again.
// This prevents a fullscreen-sized canvas from keeping a dialog artificially tall.
card.getBoundingClientRect();
requestAnimationFrame(() => {
redrawHistoryChart(canvas.id);
const wrap = canvas.parentElement;
if (wrap && state) {
wrap.scrollLeft = state.scrollLeft;
wrap.scrollTop = state.scrollTop;
}
const dialog = card.closest('dialog');
if (dialog?.open) {
dialog.style.removeProperty('height');
dialog.style.removeProperty('max-height');
requestAnimationFrame(() => {
dialog.style.removeProperty('height');
dialog.style.removeProperty('max-height');
});
}
});
}
function toggleChartFullscreen(id) {
@@ -278,6 +331,18 @@ function toggleChartFullscreen(id) {
return;
}
const wrap = canvas.parentElement;
chartFullscreenState.set(card, {
canvasStyleWidth: canvas.style.width,
canvasStyleHeight: canvas.style.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
wrapStyleHeight: wrap?.style.height || '',
wrapStyleMinHeight: wrap?.style.minHeight || '',
wrapStyleMaxHeight: wrap?.style.maxHeight || '',
scrollLeft: wrap?.scrollLeft || 0,
scrollTop: wrap?.scrollTop || 0,
});
card.classList.add('chart-fullscreen-fallback');
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');