Optimize frontend rendering: delta updates, rAF debouncing, cached DOM refs

- Disable Chart.js animations on real-time FPS and perf charts
- Dashboard: delta-update profile badges on state changes instead of full DOM rebuild
- Dashboard: cache querySelector results in Map for metrics update loop
- Dashboard: debounce poll interval slider restart (300ms)
- Calibration: debounce ResizeObserver and span drag via requestAnimationFrame
- Calibration: batch updateCalibrationPreview canvas render into rAF

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 23:06:39 +03:00
parent fbf597dc29
commit 755077607a
3 changed files with 105 additions and 21 deletions

View File

@@ -45,6 +45,9 @@ class CalibrationModal extends Modal {
const calibModal = new CalibrationModal(); const calibModal = new CalibrationModal();
let _dragRaf = null;
let _previewRaf = null;
/* ── Public API (exported names unchanged) ────────────────────── */ /* ── Public API (exported names unchanged) ────────────────────── */
export async function showCalibration(deviceId) { export async function showCalibration(deviceId) {
@@ -117,8 +120,12 @@ export async function showCalibration(deviceId) {
if (!window._calibrationResizeObserver) { if (!window._calibrationResizeObserver) {
window._calibrationResizeObserver = new ResizeObserver(() => { window._calibrationResizeObserver = new ResizeObserver(() => {
updateSpanBars(); if (window._calibrationResizeRaf) return;
renderCalibrationCanvas(); window._calibrationResizeRaf = requestAnimationFrame(() => {
window._calibrationResizeRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
}); });
} }
window._calibrationResizeObserver.observe(preview); window._calibrationResizeObserver.observe(preview);
@@ -202,8 +209,12 @@ export function updateCalibrationPreview() {
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0); if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
}); });
updateSpanBars(); if (_previewRaf) cancelAnimationFrame(_previewRaf);
renderCalibrationCanvas(); _previewRaf = requestAnimationFrame(() => {
_previewRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
} }
export function renderCalibrationCanvas() { export function renderCalibrationCanvas() {
@@ -509,11 +520,19 @@ function initSpanDrag() {
if (handleType === 'start') span.start = Math.min(fraction, span.end - MIN_SPAN); if (handleType === 'start') span.start = Math.min(fraction, span.end - MIN_SPAN);
else span.end = Math.max(fraction, span.start + MIN_SPAN); else span.end = Math.max(fraction, span.start + MIN_SPAN);
updateSpanBars(); if (!_dragRaf) {
renderCalibrationCanvas(); _dragRaf = requestAnimationFrame(() => {
_dragRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
}
} }
function onMouseUp() { function onMouseUp() {
if (_dragRaf) { cancelAnimationFrame(_dragRaf); _dragRaf = null; }
updateSpanBars();
renderCalibrationCanvas();
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
} }
@@ -548,11 +567,19 @@ function initSpanDrag() {
span.start = newStart; span.start = newStart;
span.end = newStart + spanWidth; span.end = newStart + spanWidth;
updateSpanBars(); if (!_dragRaf) {
renderCalibrationCanvas(); _dragRaf = requestAnimationFrame(() => {
_dragRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
}
} }
function onMouseUp() { function onMouseUp() {
if (_dragRaf) { cancelAnimationFrame(_dragRaf); _dragRaf = null; }
updateSpanBars();
renderCalibrationCanvas();
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
} }

View File

@@ -19,6 +19,7 @@ let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render let _lastRunningIds = []; // sorted target IDs from previous render
let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null; let _uptimeTimer = null;
let _metricsElements = new Map();
function _loadFpsHistory() { function _loadFpsHistory() {
try { try {
@@ -99,7 +100,7 @@ function _createFpsChart(canvasId, history, fpsTarget) {
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: true, animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } }, plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { scales: {
x: { display: false }, x: { display: false },
@@ -124,6 +125,18 @@ function _initFpsCharts(runningTargetIds) {
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget); _fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget);
} }
_saveFpsHistory(); _saveFpsHistory();
_cacheMetricsElements(runningTargetIds);
}
function _cacheMetricsElements(runningIds) {
_metricsElements.clear();
for (const id of runningIds) {
_metricsElements.set(id, {
fps: document.querySelector(`[data-fps-text="${id}"]`),
errors: document.querySelector(`[data-errors-text="${id}"]`),
row: document.querySelector(`[data-target-id="${id}"]`),
});
}
} }
/** Update running target metrics in-place (no HTML rebuild). */ /** Update running target metrics in-place (no HTML rebuild). */
@@ -152,17 +165,18 @@ function _updateRunningMetrics(enrichedRunning) {
_setUptimeBase(target.id, metrics.uptime_seconds); _setUptimeBase(target.id, metrics.uptime_seconds);
} }
// Update text values // Update text values (use cached refs, fallback to querySelector)
const fpsEl = document.querySelector(`[data-fps-text="${target.id}"]`); const cached = _metricsElements.get(target.id);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
if (fpsEl) fpsEl.innerHTML = `${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span>`; if (fpsEl) fpsEl.innerHTML = `${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
const errorsEl = document.querySelector(`[data-errors-text="${target.id}"]`); const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
if (errorsEl) errorsEl.textContent = `${errors > 0 ? '⚠️' : '✅'} ${errors}`; if (errorsEl) errorsEl.textContent = `${errors > 0 ? '⚠️' : '✅'} ${errors}`;
// Update health dot // Update health dot
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) { if (isLed) {
const row = document.querySelector(`[data-target-id="${target.id}"]`); const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
if (row) { if (row) {
const dot = row.querySelector('.health-dot'); const dot = row.querySelector('.health-dot');
if (dot && state.device_last_checked != null) { if (dot && state.device_last_checked != null) {
@@ -174,19 +188,55 @@ function _updateRunningMetrics(enrichedRunning) {
_saveFpsHistory(); _saveFpsHistory();
} }
function _updateProfilesInPlace(profiles) {
for (const p of profiles) {
const card = document.querySelector(`[data-profile-id="${p.id}"]`);
if (!card) continue;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) {
if (!p.enabled) {
badge.className = 'dashboard-badge-stopped';
badge.textContent = t('profiles.status.disabled');
} else if (p.is_active) {
badge.className = 'dashboard-badge-active';
badge.textContent = t('profiles.status.active');
} else {
badge.className = 'dashboard-badge-stopped';
badge.textContent = t('profiles.status.inactive');
}
}
const metricVal = card.querySelector('.dashboard-metric-value');
if (metricVal) {
const cnt = p.target_ids.length;
const active = (p.active_target_ids || []).length;
metricVal.textContent = p.is_active ? `${active}/${cnt}` : `${cnt}`;
}
const btn = card.querySelector('.dashboard-target-actions .btn');
if (btn) {
btn.className = `btn btn-icon ${p.enabled ? 'btn-warning' : 'btn-success'}`;
btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`);
btn.textContent = p.enabled ? '⏸' : '▶';
}
}
}
function _renderPollIntervalSelect() { function _renderPollIntervalSelect() {
const sec = Math.round(dashboardPollInterval / 1000); const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap" onclick="event.stopPropagation()"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`; return `<span class="dashboard-poll-wrap" onclick="event.stopPropagation()"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
} }
let _pollDebounce = null;
export function changeDashboardPollInterval(value) { export function changeDashboardPollInterval(value) {
const ms = parseInt(value, 10) * 1000;
setDashboardPollInterval(ms);
startAutoRefresh();
stopPerfPolling();
startPerfPolling();
const label = document.querySelector('.dashboard-poll-value'); const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`; if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => {
const ms = parseInt(value, 10) * 1000;
setDashboardPollInterval(ms);
startAutoRefresh();
stopPerfPolling();
startPerfPolling();
}, 300);
} }
function _getCollapsedSections() { function _getCollapsedSections() {
@@ -289,11 +339,18 @@ export async function loadDashboard(forceFullRender = false) {
const newRunningIds = running.map(t => t.id).sort().join(','); const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
if (!forceFullRender && hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') { const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds;
if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running); _updateRunningMetrics(running);
set_dashboardLoading(false); set_dashboardLoading(false);
return; return;
} }
if (structureUnchanged && forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running);
_updateProfilesInPlace(profiles);
set_dashboardLoading(false);
return;
}
if (profiles.length > 0) { if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active); const activeProfiles = profiles.filter(p => p.is_active);
@@ -475,7 +532,7 @@ function renderDashboardProfile(profile) {
const activeCount = (profile.active_target_ids || []).length; const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile"> return `<div class="dashboard-target dashboard-profile" data-profile-id="${profile.id}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">📋</span> <span class="dashboard-target-icon">📋</span>
<div> <div>

View File

@@ -77,7 +77,7 @@ function _createChart(canvasId, color, fillColor) {
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: true, animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } }, plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { scales: {
x: { display: false }, x: { display: false },