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(zoneHaEntityId).filter(Boolean), ...app.zones.map(zone => zone.ha_outdoor_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 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 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, 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 && 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(); 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 === 'energy') { const targets = normalizeEnergyHistoryTargets(); hasEnergyTargets = targets.length > 0; const targetOptions = targets.map(target => ``).join(''); host.innerHTML = targets.length ? `
${esc(tr('energy.targets'))}
${esc(energyTargetPickerSummary(targets))}${app.historyEnergyTargets.length}/8
${targetOptions}
${esc(tr('energy.multiselectHint'))}
` : ''; 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 === 'network') { const targets = app.historyNetworkTargets || []; const targetOptions = targets.map(target => ``).join(''); const jitterEnabled = app.historyNetworkShowJitter !== false; host.innerHTML = `
`; const select = $('#historyNetworkSelect'); if ([...select.options].some(option => option.value === app.historyNetworkTarget)) select.value = app.historyNetworkTarget; } 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'))}`; } configureHistoryRangeControl(app.historyTab, host, hasEnergyTargets); } async function loadHistory() { if (app.historyLoading) { app.historyReloadPending = true; return; } app.historyLoading = true; const hours = $('#historyHours')?.value || '24'; try { if (app.historyTab === 'energy') { await loadEnergyHistory(); renderHistoryNavigation(); renderHistoryPage(); if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); return; } if (app.historyTab === 'network') { const target = app.historyNetworkTarget && app.historyNetworkTarget !== 'all' ? `&target_id=${encodeURIComponent(app.historyNetworkTarget)}` : ''; const data = await api(`/api/history/network?hours=${encodeURIComponent(hours)}&limit=20000${target}`); app.historyNetwork = data.readings || []; app.historyNetworkTargets = data.targets || []; renderHistoryNavigation(); renderHistoryPage(); if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); return; } 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; if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); } } } 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}`; } 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('.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)); if (runtime.kind === 'energy') { drawEnergyBarChart(canvas, runtime.series, runtime.options); } else { 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.innerHTML = uiIcon(active ? 'close' : 'fullscreen'); button.title = tr(active ? 'history.exitFullscreen' : 'history.fullscreen'); 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'); card.removeAttribute('role'); 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) 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) { if (!window.matchMedia('(min-width: 761px)').matches) return; const canvas = document.getElementById(id); const card = canvas?.closest('.chart-card'); if (!card) return; const active = chartCardIsFullscreen(card); const opened = document.querySelector('.chart-card.chart-fullscreen-fallback'); if (opened && opened !== card) closeChartPreview(opened); if (active) { closeChartPreview(card); 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'); 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('.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, tooltipUnit = ' °C') { 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}${tooltipUnit}`; } 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, tooltipUnit } = 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))}
${values.map(({ item, point }) => { const sampleTime = Math.abs(point.ts - snapped) > 1000 ? `${esc(chartPreciseTime(point.ts))}` : ''; return `
${esc(item.label)}${esc(formatChartTooltipValue(item, point, binaryLabels, tooltipUnit))}${sampleTime}
`; }).join('')}
`; 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 tooltipLeft = px + 12; if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12; tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`; tooltip.style.top = `${pad.top + 8}px`; canvas.dataset.chartHoverTs = String(snapped); }; const showFromClientX = (clientX) => { const localX = canvasX(clientX); const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs); showAtTimestamp(targetTs); }; const updateSelection = (currentX) => { if (!drag || !selection) return; const left = Math.min(drag.startX, currentX); const right = Math.max(drag.startX, currentX); drag.lastX = currentX; drag.moved = Math.abs(right - left) >= 7; if (!drag.moved) return hideSelection(); selection.hidden = false; selection.style.left = `${left}px`; selection.style.width = `${Math.max(1, right - left)}px`; selection.style.top = `${pad.top}px`; selection.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`; }; canvas.onpointerdown = event => { if (event.pointerType !== 'mouse' || event.button !== 0) return; const startX = canvasX(event.clientX); drag = { pointerId: event.pointerId, startX, lastX: startX, moved: false }; hide(); hideSelection(); canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); }; canvas.onpointermove = event => { if (event.pointerType === 'touch') return; if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) { updateSelection(canvasX(event.clientX)); event.preventDefault(); return; } showFromClientX(event.clientX); }; const finishPointer = event => { if (event.pointerType !== 'mouse' || drag?.pointerId !== event.pointerId) return; const currentX = canvasX(event.clientX); const selectedWidth = Math.abs(currentX - drag.startX); const center = (currentX + drag.startX) / 2; const moved = drag.moved && selectedWidth >= 12; drag = null; hideSelection(); try { canvas.releasePointerCapture?.(event.pointerId); } catch (_) { } if (moved) { const currentZoom = clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM); const factor = Math.max(1, wrap.clientWidth / Math.max(1, selectedWidth)); setChartZoom(canvas.id, currentZoom * factor, center / Math.max(1, width), wrap.clientWidth / 2); } else showFromClientX(event.clientX); }; canvas.onpointerup = finishPointer; canvas.onpointercancel = event => { if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) { drag = null; hideSelection(); } }; canvas.onpointerleave = event => { if (event.pointerType !== 'touch' && !drag) hide(); }; canvas.onkeydown = event => { if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || !timestamps.length) return; event.preventDefault(); const current = Number(canvas.dataset.chartHoverTs); let index = Number.isFinite(current) ? timestamps.findIndex(ts => ts === current) : -1; if (index < 0) index = event.key === 'ArrowRight' ? 0 : timestamps.length - 1; else index = clamp(index + (event.key === 'ArrowRight' ? 1 : -1), 0, timestamps.length - 1); showAtTimestamp(timestamps[index]); }; } function drawLineChart(canvas, series, rows, { height = 340, minValue = null, maxValue = null, binaryLabels = false, axisSuffix = '°', tooltipUnit = ' °C', axisDigits = 1 } = {}) { if (!canvas) return; const options = { height, minValue, maxValue, binaryLabels, axisSuffix, tooltipUnit, axisDigits }; if (canvas.id) chartRuntime.set(canvas.id, { series, rows, options }); const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index)); if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height); const sortedRows = [...rows].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); const prepared = prepareCanvas(canvas, height); const { ctx, width } = prepared; height = prepared.height; const text = cssColor('--muted', '#888'), grid = cssColor('--grid', '#333'); const compactPlot = width < 520; const pad = compactPlot ? { left: 43, right: 12, top: 16, bottom: 34 } : { left: 54, right: 20, top: 20, bottom: 42 }; const allValues = []; visibleSeries.forEach(item => sortedRows.forEach(row => { const value = item.value(row); if (Number.isFinite(value)) allValues.push(value); })); if (!allValues.length) return drawEmptyChart(canvas, height); let min = minValue == null ? Math.floor(Math.min(...allValues) - 1) : minValue; let max = maxValue == null ? Math.ceil(Math.max(...allValues) + 1) : maxValue; if (max - min < 2) { min -= 1; max += 1; } const firstTs = new Date(sortedRows[0].timestamp).getTime(); const lastTs = new Date(sortedRows[sortedRows.length - 1].timestamp).getTime(); const span = Math.max(1, lastTs - firstTs); const x = row => pad.left + (new Date(row.timestamp).getTime() - firstTs) / span * (width - pad.left - pad.right); const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom); ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1; for (let i = 0; i <= 5; i++) { const value = min + (max - min) * i / 5, py = y(value); ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(width - pad.right, py); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(axisDigits)}${axisSuffix}`, pad.left - 8, py + 3); } const ticks = width < 420 ? 2 : width < 620 ? 3 : 5; for (let i = 0; i <= ticks; i++) { const idx = Math.min(sortedRows.length - 1, Math.round(i * (sortedRows.length - 1) / ticks)); const px = x(sortedRows[idx]); ctx.textAlign = 'center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp, $('#historyHours')?.value), px, height - 14); } visibleSeries.forEach(item => { ctx.beginPath(); ctx.strokeStyle = item.color; ctx.lineWidth = item.width || 2; ctx.setLineDash(item.dash || []); let started = false, prev = null; sortedRows.forEach(row => { const value = item.value(row); if (!Number.isFinite(value)) return; const px = x(row), py = y(value); if (!started || prev === null) { ctx.moveTo(px, py); started = true; } else if (item.step) { ctx.lineTo(px, y(prev)); ctx.lineTo(px, py); } else ctx.lineTo(px, py); prev = value; }); ctx.stroke(); ctx.setLineDash([]); }); bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels, tooltipUnit }); } 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 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 = `${esc(energyBucketTooltipLabel(start, interval))}
${rows.map(({ item, value }) => `
${esc(item.label)}${esc(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh
`).join('')}
`; 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) ? 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 pad = { left: 58, right: 18, top: 20, bottom: 48 }; 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, 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; const y = pad.top + plotH - (value / max) * plotH; 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); } 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, starts.length); for (let i = 0; i < ticks; i++) { 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(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); } function renderLegend(host, series) { if (!host) return; const chartId = host.id.replace(/Legend$/, ''); host.innerHTML = series.map((item, index) => { const hidden = isChartSeriesHidden(chartId, item, index); const title = tr(hidden ? 'history.legendShow' : 'history.legendHide', { name: item.label }); return ``; }).join(''); } function historyChartMarkup(id, title, hint, compact = false) { const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM); const percent = Math.round(zoom * 100); return `

${esc(title)}

${esc(hint || '')}

${esc(tr('history.hoverHint'))}
`; } function historySeriesColor(index) { return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index * 67) % 360} 68% 55%)`); } document.addEventListener('keydown', event => { if (event.key === 'Escape') closeChartPreview(document.querySelector('.chart-card.chart-fullscreen-fallback')); });