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 prepareCanvas(canvas, height) { const rect = canvas.getBoundingClientRect(); const wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720); const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4); const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom)); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,width,height); return {ctx,width,height}; } function drawEmptyChart(canvas, height=340) { if (!canvas) return; const {ctx,width} = prepareCanvas(canvas,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(lo0?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) })); const touches=new Map(); let drag=null; let pinch=null; let touchPan=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))}${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'){ if(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(); return; } if(event.pointerType==='touch'){ touches.set(event.pointerId,{x:event.clientX,y:event.clientY}); if(touches.size===1){ touchPan={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,scrollLeft:wrap.scrollLeft,moved:false}; showFromClientX(event.clientX); } if(touches.size===2){ touchPan=null; const points=[...touches.values()]; const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y); const midX=(points[0].x+points[1].x)/2; const rect=canvas.getBoundingClientRect(); const wrapRect=wrap.getBoundingClientRect(); pinch={ startDistance:Math.max(1,distance), startZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), baseWidth:(rect.width||width)/clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), anchorRatio:clamp((midX-rect.left)/Math.max(1,rect.width),0,1), viewportX:clamp(midX-wrapRect.left,0,wrap.clientWidth), previewZoom:clamp(Number(app.chartZooms[canvas.id] || 1),1,MAX_CHART_ZOOM), }; hide(); event.preventDefault(); } } }; canvas.onpointermove=event=>{ if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ updateSelection(canvasX(event.clientX)); event.preventDefault(); return; } if(event.pointerType==='touch' && touches.has(event.pointerId)){ touches.set(event.pointerId,{x:event.clientX,y:event.clientY}); if(pinch && touches.size>=2){ const points=[...touches.values()].slice(0,2); const distance=Math.hypot(points[0].x-points[1].x,points[0].y-points[1].y); pinch.previewZoom=clamp(pinch.startZoom*(distance/pinch.startDistance),1,MAX_CHART_ZOOM); const previewWidth=pinch.baseWidth*pinch.previewZoom; canvas.style.width=`${previewWidth}px`; wrap.scrollLeft=Math.max(0,pinch.anchorRatio*previewWidth-pinch.viewportX); const reset=canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]'); if(reset) reset.textContent=`${Math.round(pinch.previewZoom*100)}%`; event.preventDefault(); } else if(touchPan?.pointerId===event.pointerId && touches.size===1){ const dx=event.clientX-touchPan.startX; const dy=event.clientY-touchPan.startY; if(Math.abs(dx)>6 && Math.abs(dx)>Math.abs(dy)){ touchPan.moved=true; hide(); wrap.scrollLeft=Math.max(0,touchPan.scrollLeft-dx); event.preventDefault(); } } return; } showFromClientX(event.clientX); }; const finishPointer=event=>{ if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ 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); return; } if(event.pointerType==='touch'){ const wasPan=touchPan?.pointerId===event.pointerId ? touchPan : null; touches.delete(event.pointerId); if(pinch && touches.size<2){ const completed=pinch; pinch=null; setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX); } else if(wasPan && !wasPan.moved) showFromClientX(event.clientX); if(touches.size===0) touchPan=null; } }; canvas.onpointerup=finishPointer; canvas.onpointercancel=event=>{ if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ drag=null; hideSelection(); } if(event.pointerType==='touch'){ touches.delete(event.pointerId); if(touchPan?.pointerId===event.pointerId) touchPan=null; if(pinch && touches.size<2){ const completed=pinch; pinch=null; setChartZoom(canvas.id,completed.previewZoom,completed.anchorRatio,completed.viewportX); } else hide(); } }; 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}={}) { if (!canvas) return; const options={height,minValue,maxValue,binaryLabels}; 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 {ctx,width} = prepareCanvas(canvas,height); const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'); const pad={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(1)}°`,pad.left-8,py+3); } const ticks=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}); } 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%)`); }