function encodeChartSpec(series) { try { const raw = JSON.stringify(series || []); return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } catch (_) { return ''; } } function decodeChartSpec(encoded) { try { const padded = String(encoded || '').replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '='); const value = JSON.parse(atob(padded)); return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : []; } catch (_) { return []; } } function historyEntityOptions() { const zones = app.zones.map(zone => ``).join(''); const devices = app.devices.map(device => ``).join(''); const entities = [...new Set([ ...app.historyData.sensors.map(row => row.entity_id), ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), app.settings?.home_assistant?.outdoor_entity_id, ].filter(Boolean))].sort(); const sensors = entities.map(entity => ``).join(''); return { zones, devices, sensors, entities }; } function renderHistoryNavigation() { $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); const host = $('#historyContextControls'); if (!host) return; const options = historyEntityOptions(); if (app.historyTab === 'zones') { host.innerHTML = ``; const select = $('#historyZoneSelect'); if ([...select.options].some(option => option.value === app.historyZone)) select.value = app.historyZone; } else if (app.historyTab === 'devices') { host.innerHTML = ``; const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice; } else if (app.historyTab === 'sensors') { host.innerHTML = ``; const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor; } else if (app.historyTab === 'custom') { host.innerHTML = `${esc(tr('history.customHint'))}`; } else { host.innerHTML = `${esc(tr('history.overviewHint'))}`; } } async function loadHistory() { if (app.historyLoading) return; app.historyLoading = true; const hours = $('#historyHours')?.value || '24'; try { const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`); app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] }; app.historyCounts = data.counts || {}; renderHistoryNavigation(); renderHistoryPage(); if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); } catch (error) { toast(error.message, true); renderHistoryPage(); } finally { app.historyLoading = false; } } const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue']; const MAX_CHART_ZOOM = 16; const chartRuntime = new Map(); function chartSeriesKey(item, index) { return item.key || `${index}:${item.label}`; } function isChartSeriesHidden(chartId, item, index) { return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item, index)]; } function setChartSeriesHidden(chartId, item, index, hidden) { if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {}; const key = chartSeriesKey(item, index); if (hidden) app.chartHiddenSeries[chartId][key] = true; else delete app.chartHiddenSeries[chartId][key]; } function updateChartZoomControls(id) { const canvas = document.getElementById(id); const card = canvas?.closest('.history-chart-card'); if (!card) return; const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM); const reset = card.querySelector('[data-chart-zoom="reset"]'); const out = card.querySelector('[data-chart-zoom="out"]'); const zoomIn = card.querySelector('[data-chart-zoom="in"]'); if (reset) reset.textContent = `${Math.round(zoom * 100)}%`; if (out) out.disabled = zoom <= 1; if (zoomIn) zoomIn.disabled = zoom >= MAX_CHART_ZOOM; } function redrawHistoryChart(id) { const runtime = chartRuntime.get(id); const canvas = document.getElementById(id); if (!runtime || !canvas) return; canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM)); drawLineChart(canvas, runtime.series, runtime.rows, runtime.options); renderLegend(document.getElementById(`${id}Legend`), runtime.series); updateChartZoomControls(id); } function setChartZoom(id, value, anchorRatio = null, anchorViewportX = null) { const canvas = document.getElementById(id); const wrap = canvas?.parentElement; if (!canvas || !wrap) return; const oldWidth = canvas.getBoundingClientRect().width || 1; const ratio = Number.isFinite(anchorRatio) ? clamp(anchorRatio, 0, 1) : clamp((wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth, 0, 1); const viewportX = Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth / 2; const next = clamp(Number(value) || 1, 1, MAX_CHART_ZOOM); app.chartZooms[id] = next; redrawHistoryChart(id); const nextCanvas = document.getElementById(id); const nextWrap = nextCanvas?.parentElement; if (!nextCanvas || !nextWrap) return; const nextWidth = nextCanvas.getBoundingClientRect().width || 1; const previousBehavior = nextWrap.style.scrollBehavior; nextWrap.style.scrollBehavior = 'auto'; nextWrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX); nextWrap.style.scrollBehavior = previousBehavior; } function cssColor(name, fallback) { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; } function timeLabel(ts, hours) { const options = Number(hours) > 48 ? { day: '2-digit', month: '2-digit', hour: '2-digit' } : { hour: '2-digit', minute: '2-digit' }; return new Date(ts).toLocaleString(locale(), options); } function chartCardIsFullscreen(card) { return !!card && card.classList.contains('chart-fullscreen-fallback'); } function updateChartFullscreenButton(card) { if (!card) return; const button = card.querySelector('[data-chart-fullscreen]'); if (!button) return; const active = chartCardIsFullscreen(card); button.textContent = active ? '×' : '⛶'; button.title = tr(active ? 'history.exitFullscreen' : 'history.fullscreen'); button.setAttribute('aria-label', button.title); } function closeChartPreview(card) { if (!card) return; card.classList.remove('chart-fullscreen-fallback'); card.removeAttribute('role'); card.removeAttribute('aria-modal'); card.removeAttribute('aria-label'); document.body.classList.remove('chart-fullscreen-open'); updateChartFullscreenButton(card); const canvas = card.querySelector('canvas[id]'); if (canvas?.id) requestAnimationFrame(() => redrawHistoryChart(canvas.id)); } function toggleChartFullscreen(id) { if (!window.matchMedia('(min-width: 761px)').matches) return; const canvas = document.getElementById(id); const card = canvas?.closest('.history-chart-card'); if (!card) return; const active = chartCardIsFullscreen(card); const opened = document.querySelector('.history-chart-card.chart-fullscreen-fallback'); if (opened && opened !== card) closeChartPreview(opened); if (active) { closeChartPreview(card); return; } card.classList.add('chart-fullscreen-fallback'); card.setAttribute('role', 'dialog'); card.setAttribute('aria-modal', 'true'); card.setAttribute('aria-label', card.querySelector('h3')?.textContent || tr('history.fullscreen')); document.body.classList.add('chart-fullscreen-open'); updateChartFullscreenButton(card); requestAnimationFrame(() => redrawHistoryChart(id)); } function prepareCanvas(canvas, height) { const rect = canvas.getBoundingClientRect(); const wrap = canvas.parentElement; const wrapWidth = Math.floor(wrap?.clientWidth || rect.width || 720); const fullscreen = chartCardIsFullscreen(canvas.closest('.history-chart-card')); const wrapHeight = fullscreen ? Math.floor(wrap?.clientHeight || 0) : 0; const actualHeight = wrapHeight || height; const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, MAX_CHART_ZOOM); // Keep the original chart geometry on narrow screens and let the wrapper scroll horizontally. const baseWidth = Math.max(720, wrapWidth); const width = Math.max(1, Math.floor(baseWidth * zoom)); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = actualHeight * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, actualHeight); return { ctx, width, height: actualHeight }; } function drawEmptyChart(canvas, height = 340) { if (!canvas) return; const prepared = prepareCanvas(canvas, height); const { ctx, width } = prepared; height = prepared.height; ctx.fillStyle = cssColor('--muted', '#888'); ctx.font = '13px system-ui'; ctx.textAlign = 'center'; ctx.fillText(tr('history.noData'), width / 2, height / 2); const wrap = canvas.parentElement; const tooltip = wrap?.querySelector('.chart-tooltip'); const line = wrap?.querySelector('.chart-hover-line'); if (tooltip) tooltip.hidden = true; if (line) line.hidden = true; canvas.onpointermove = null; canvas.onpointerleave = null; canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onkeydown = null; } function chartPreciseTime(timestamp) { return new Intl.DateTimeFormat(locale(), { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(timestamp)); } function nearestPoint(points, targetTs) { if (!points.length) return null; let lo = 0, hi = points.length - 1; while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid; } const after = points[lo]; const before = lo > 0 ? points[lo - 1] : null; if (!before) return after; return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after; } function formatChartTooltipValue(item, point, binaryLabels) { if (!point || !Number.isFinite(point.value)) return '—'; if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row)); const formatted = Number(point.value).toLocaleString(locale(), { minimumFractionDigits: 1, maximumFractionDigits: 2 }); return binaryLabels ? formatted : `${formatted} °C`; } function bindChartTooltip(canvas, series, sortedRows, geometry) { const wrap = canvas?.parentElement; const tooltip = wrap?.querySelector('.chart-tooltip'); const line = wrap?.querySelector('.chart-hover-line'); const selection = wrap?.querySelector('.chart-selection'); if (!canvas || !wrap || !tooltip || !line) return; const { pad, width, height, firstTs, lastTs, binaryLabels } = geometry; const plotWidth = width - pad.left - pad.right; const timestamps = [...new Set(sortedRows.map(row => new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a, b) => a - b); const timestampPoints = timestamps.map(ts => ({ ts, value: ts })); const pointsBySeries = series.map(item => ({ item, points: sortedRows.map(row => ({ row, ts: new Date(row.timestamp).getTime(), value: item.value(row) })) .filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value)) .sort((a, b) => a.ts - b.ts) })); let drag = null; const hide = () => { tooltip.hidden = true; line.hidden = true; }; const hideSelection = () => { if (selection) selection.hidden = true; }; const canvasX = clientX => { const rect = canvas.getBoundingClientRect(); return clamp(clientX - rect.left, pad.left, width - pad.right); }; const showAtTimestamp = (targetTs) => { if (!timestamps.length) return hide(); const snapped = nearestPoint(timestampPoints, targetTs)?.ts; if (!Number.isFinite(snapped)) return hide(); const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth; const values = pointsBySeries.map(({ item, points }) => ({ item, point: nearestPoint(points, snapped) })).filter(entry => entry.point); if (!values.length) return hide(); $$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; }); $$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; }); tooltip.innerHTML = `${esc(chartPreciseTime(snapped))}
${esc(hint || '')}
${esc(tr('history.hoverHint'))}