Files
2026-09-17 08:52:02 +02:00

739 lines
40 KiB
JavaScript

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(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 => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).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.slice(0, 8).map(item => item.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 = `<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 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>`
: '';
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 => `<option value="${esc(target.id)}">${esc(target.name)}</option>`).join('');
const jitterEnabled = app.historyNetworkShowJitter !== false;
host.innerHTML = `<div class="history-network-controls"><label><span>${esc(tr('history.networkTarget'))}</span><select id="historyNetworkSelect"><option value="all">${esc(tr('history.allNetworkTargets'))}</option>${targetOptions}</select></label><button type="button" class="secondary history-jitter-toggle${jitterEnabled ? ' active' : ''}" data-action="toggle-network-jitter" aria-pressed="${jitterEnabled ? 'true' : 'false'}" title="${esc(tr('history.networkJitterToggleHint'))}">${esc(tr(jitterEnabled ? 'history.networkJitterOn' : 'history.networkJitterOff'))}</button></div>`;
const select = $('#historyNetworkSelect'); if ([...select.options].some(option => option.value === app.historyNetworkTarget)) select.value = app.historyNetworkTarget;
} 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>`;
}
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 = `<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, tooltipUnit))}${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, 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 = `<strong class="chart-tooltip-time">${esc(energyBucketTooltipLabel(start, interval))}</strong><div class="chart-tooltip-values">${rows.map(({ item, value }) => `<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(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh</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 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 `<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${item.dash?.length ? ' is-dashed' : ''}" 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 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' : ''}>${uiIcon('minus')}</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' : ''}>${uiIcon('plus')}</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'))}">${uiIcon('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('.chart-card.chart-fullscreen-fallback'));
});