Files
gree-controller/web/js/charts.js
T
2026-09-14 16:32:28 +02:00

513 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => `<option value="${esc(zone.id)}">${esc(zone.name)}</option>`).join('');
const devices = app.devices.map(device => `<option value="${esc(device.id)}">${esc(device.name)}</option>`).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 => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).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 = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
const select = $('#historyZoneSelect'); if ([...select.options].some(option => option.value === app.historyZone)) select.value = app.historyZone;
} else if (app.historyTab === 'devices') {
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') {
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
if (!app.historyEnergyDevice && energyDevices.length) app.historyEnergyDevice = energyDevices[0].id;
const deviceOptions = energyDevices.map(device => `<option value="${esc(device.id)}">${esc(device.name)} · ${esc(deviceTransportLabel(device))}</option>`).join('');
host.innerHTML = energyDevices.length
? `<label><span>${esc(tr('common.device'))}</span><select id="historyEnergyDeviceSelect">${deviceOptions}</select></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="monthly">${esc(tr('energy.monthly'))}</option></select></label>`
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
const deviceSelect = $('#historyEnergyDeviceSelect'); if (deviceSelect && [...deviceSelect.options].some(option => option.value === app.historyEnergyDevice)) deviceSelect.value = app.historyEnergyDevice;
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
} 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;
} else if (app.historyTab === 'custom') {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
} else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
}
}
async function loadHistory() {
if (app.historyLoading) 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;
}
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));
if (runtime.kind === 'energy') {
drawEnergyBarChart(canvas, runtime.buckets, 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.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 = `<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({ item, point }) => {
const sampleTime = Math.abs(point.ts - snapped) > 1000 ? `<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>` : '';
return `<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(formatChartTooltipValue(item, point, binaryLabels))}${sampleTime}</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 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 } = {}) {
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 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(1)}°`, 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 });
}
function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
if (!canvas) return;
const options = { height };
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', buckets, options });
if (!buckets?.length) return drawEmptyChart(canvas, height);
const prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.height;
const text = cssColor('--muted', '#888');
const grid = cssColor('--grid', '#333');
const fill = cssColor('--accent', '#3ecf8e');
const pad = { left: 58, right: 18, top: 20, bottom: 48 };
const values = buckets.map(row => Math.max(0, Number(row.consumption_kwh) || 0));
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, buckets.length);
const barW = Math.max(2, Math.min(slot * 0.72, 42));
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);
}
ctx.fillStyle = fill;
buckets.forEach((row, index) => {
const value = values[index];
const x = pad.left + slot * index + (slot - barW) / 2;
const barH = (value / max) * plotH;
ctx.fillRect(x, pad.top + plotH - barH, barW, barH);
});
ctx.fillStyle = text;
const ticks = Math.min(6, buckets.length);
for (let i = 0; i < ticks; i++) {
const index = ticks === 1 ? 0 : Math.round(i * (buckets.length - 1) / (ticks - 1));
const x = pad.left + slot * index + slot / 2;
ctx.textAlign = 'center';
ctx.fillText(timeLabel(buckets[index].start, $('#historyHours')?.value), x, height - 18);
}
ctx.textAlign = 'left';
ctx.fillText('kWh', 8, pad.top + 4);
const legend = document.getElementById(`${canvas.id}Legend`);
if (legend) legend.innerHTML = `<span class="legend-item"><i class="legend-line" style="--legend-color:${esc(fill)}"></i><span>kWh</span></span>`;
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 `<button type="button" class="legend-item${hidden ? ' is-hidden' : ''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden ? 'false' : 'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
}).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 `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-title-actions"><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}></button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>+</button></div><button type="button" class="chart-fullscreen-button" data-chart-fullscreen="${esc(id)}" title="${esc(tr('history.fullscreen'))}" aria-label="${esc(tr('history.fullscreen'))}">⛶</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
}
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('.history-chart-card.chart-fullscreen-fallback'));
});