Files
gree-controller/web/js/charts.js
T
2026-09-03 22:18:32 +02:00

515 lines
26 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 === '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 {
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
app.historyCounts = data.counts || {};
renderHistoryNavigation();
renderHistoryPage();
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
} catch (error) {
toast(error.message, true);
renderHistoryPage();
} finally { app.historyLoading = false; }
}
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
const MAX_CHART_ZOOM = 16;
const chartRuntime = new Map();
function chartSeriesKey(item, index) {
return item.key || `${index}:${item.label}`;
}
function isChartSeriesHidden(chartId, item, index) {
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item, index)];
}
function setChartSeriesHidden(chartId, item, index, hidden) {
if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {};
const key = chartSeriesKey(item, index);
if (hidden) app.chartHiddenSeries[chartId][key] = true;
else delete app.chartHiddenSeries[chartId][key];
}
function updateChartZoomControls(id) {
const canvas = document.getElementById(id);
const card = canvas?.closest('.history-chart-card');
if (!card) return;
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
const reset = card.querySelector('[data-chart-zoom="reset"]');
const out = card.querySelector('[data-chart-zoom="out"]');
const zoomIn = card.querySelector('[data-chart-zoom="in"]');
if (reset) reset.textContent = `${Math.round(zoom * 100)}%`;
if (out) out.disabled = zoom <= 1;
if (zoomIn) zoomIn.disabled = zoom >= MAX_CHART_ZOOM;
}
function redrawHistoryChart(id) {
const runtime = chartRuntime.get(id);
const canvas = document.getElementById(id);
if (!runtime || !canvas) return;
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
updateChartZoomControls(id);
}
function setChartZoom(id, value, anchorRatio = null, anchorViewportX = null) {
const canvas = document.getElementById(id);
const wrap = canvas?.parentElement;
if (!canvas || !wrap) return;
const oldWidth = canvas.getBoundingClientRect().width || 1;
const ratio = Number.isFinite(anchorRatio) ? clamp(anchorRatio, 0, 1) : clamp((wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth, 0, 1);
const viewportX = Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth / 2;
const next = clamp(Number(value) || 1, 1, MAX_CHART_ZOOM);
app.chartZooms[id] = next;
redrawHistoryChart(id);
const nextCanvas = document.getElementById(id);
const nextWrap = nextCanvas?.parentElement;
if (!nextCanvas || !nextWrap) return;
const nextWidth = nextCanvas.getBoundingClientRect().width || 1;
const previousBehavior = nextWrap.style.scrollBehavior;
nextWrap.style.scrollBehavior = 'auto';
nextWrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX);
nextWrap.style.scrollBehavior = previousBehavior;
}
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function timeLabel(ts, hours) {
const options = Number(hours) > 48 ? { day: '2-digit', month: '2-digit', hour: '2-digit' } : { hour: '2-digit', minute: '2-digit' };
return new Date(ts).toLocaleString(locale(), options);
}
function chartCardIsFullscreen(card) {
return !!card && card.classList.contains('chart-fullscreen-fallback');
}
function updateChartFullscreenButton(card) {
if (!card) return;
const button = card.querySelector('[data-chart-fullscreen]');
if (!button) return;
const active = chartCardIsFullscreen(card);
button.textContent = active ? '×' : '⛶';
button.title = tr(active ? 'history.exitFullscreen' : 'history.fullscreen');
button.setAttribute('aria-label', button.title);
}
function closeChartPreview(card) {
if (!card) return;
card.classList.remove('chart-fullscreen-fallback');
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) {
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');
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 card = canvas.closest('.history-chart-card');
const fullscreen = chartCardIsFullscreen(card);
const fullscreenHeight = fullscreen ? Math.floor(wrap?.clientHeight || 0) : 0;
const actualHeight = fullscreenHeight || height;
const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4);
// Regular charts keep a useful minimum plotting width and may scroll horizontally.
// In fullscreen, 100% zoom must fit the available viewport instead of forcing 720px.
const baseWidth = fullscreen ? Math.max(1, wrapWidth) : 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)
}));
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 = `<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') {
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 prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.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 `<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'));
});