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

View File

@@ -19,6 +19,7 @@ let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null;
let _metricsElements = new Map();
function _loadFpsHistory() {
try {
@@ -99,7 +100,7 @@ function _createFpsChart(canvasId, history, fpsTarget) {
options: {
responsive: true,
maintainAspectRatio: false,
animation: true,
animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
@@ -124,6 +125,18 @@ function _initFpsCharts(runningTargetIds) {
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget);
}
_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). */
@@ -152,17 +165,18 @@ function _updateRunningMetrics(enrichedRunning) {
_setUptimeBase(target.id, metrics.uptime_seconds);
}
// Update text values
const fpsEl = document.querySelector(`[data-fps-text="${target.id}"]`);
// Update text values (use cached refs, fallback to querySelector)
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>`;
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}`;
// Update health dot
const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) {
const row = document.querySelector(`[data-target-id="${target.id}"]`);
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
if (row) {
const dot = row.querySelector('.health-dot');
if (dot && state.device_last_checked != null) {
@@ -174,19 +188,55 @@ function _updateRunningMetrics(enrichedRunning) {
_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() {
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>`;
}
let _pollDebounce = null;
export function changeDashboardPollInterval(value) {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => {
const ms = parseInt(value, 10) * 1000;
setDashboardPollInterval(ms);
startAutoRefresh();
stopPerfPolling();
startPerfPolling();
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
}, 300);
}
function _getCollapsedSections() {
@@ -289,11 +339,18 @@ export async function loadDashboard(forceFullRender = false) {
const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(',');
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);
set_dashboardLoading(false);
return;
}
if (structureUnchanged && forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running);
_updateProfilesInPlace(profiles);
set_dashboardLoading(false);
return;
}
if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active);
@@ -475,7 +532,7 @@ function renderDashboardProfile(profile) {
const activeCount = (profile.active_target_ids || []).length;
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">
<span class="dashboard-target-icon">📋</span>
<div>

View File

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