Add incremental card reconciliation to prevent full DOM rebuild on auto-refresh

CardSection now diffs cards by key attributes instead of rebuilding innerHTML,
preserving DOM elements, filter input focus, scroll position, and Chart.js
instances across the 2s targets tab auto-refresh cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 00:58:38 +03:00
parent 166ec351b1
commit 27720e51aa
4 changed files with 209 additions and 54 deletions

View File

@@ -21,11 +21,11 @@ import { CardSection } from '../core/card-sections.js';
// (pattern-templates.js calls window.loadTargetsTab)
// ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()" });
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()" });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()" });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()" });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()" });
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id' });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id' });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -82,6 +82,23 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
});
}
function _updateTargetFpsChart(targetId, fpsTarget) {
const chart = _targetFpsCharts[targetId];
if (!chart) return;
const history = _targetFpsHistory[targetId] || [];
chart.data.labels = history.map(() => '');
chart.data.datasets[0].data = [...history];
chart.options.scales.y.max = fpsTarget * 1.15;
chart.update('none');
}
function _updateSubTabCounts(subTabs) {
subTabs.forEach(tab => {
const btn = document.querySelector(`.target-sub-tab-btn[data-target-sub-tab="${tab.key}"] .stream-tab-count`);
if (btn) btn.textContent = tab.count;
});
}
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
@@ -433,33 +450,47 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
const devicesHtml = ledDevices.map(device => createDeviceCard(device)).join('');
const cssHtml = Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('');
const ledTargetsHtml = ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('');
const kcTargetsHtml = kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('');
const patternTmplHtml = patternTemplates.map(pt => createPatternTemplateCard(pt)).join('');
// Build items arrays for each section
const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap) }));
const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap) }));
const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap) }));
const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }));
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
${csDevices.render(devicesHtml, ledDevices.length)}
${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)}
${csLedTargets.render(ledTargetsHtml, ledTargets.length)}
</div>`;
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
${csKCTargets.render(kcTargetsHtml, kcTargets.length)}
${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)}
</div>`;
container.innerHTML = tabBar + ledPanel + kcPanel;
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
_updateSubTabCounts(subTabs);
csDevices.reconcile(deviceItems);
csColorStrips.reconcile(cssItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
} else {
// ── First render: build full HTML ──
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
${csDevices.render(deviceItems)}
${csColorStrips.render(cssItems)}
${csLedTargets.render(ledTargetItems)}
</div>`;
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
${csKCTargets.render(kcTargetItems)}
${csPatternTemplates.render(patternItems)}
</div>`;
container.innerHTML = tabBar + ledPanel + kcPanel;
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
}
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
// Only fetch from device if we don't have a cached value yet
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
@@ -486,18 +517,28 @@ export async function loadTargetsTab() {
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Destroy old chart instances before DOM rebuild replaces canvases
for (const id of Object.keys(_targetFpsCharts)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
if (changedTargetIds) {
// Incremental: destroy only charts whose cards were replaced or removed
for (const id of changedTargetIds) {
if (_targetFpsCharts[id]) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
}
} else {
// First render: destroy all old charts
for (const id of Object.keys(_targetFpsCharts)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
}
// FPS sparkline charts: push samples and init charts after HTML rebuild
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set();
allTargets.forEach(target => {
@@ -506,18 +547,30 @@ export async function loadTargetsTab() {
if (target.state.fps_actual != null) {
_pushTargetFps(target.id, target.state.fps_actual);
}
const history = _targetFpsHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30;
const device = devices.find(d => d.id === target.device_id);
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart;
// Create chart if it doesn't exist (new or replaced card)
if (!_targetFpsCharts[target.id]) {
const history = _targetFpsHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30;
const device = devices.find(d => d.id === target.device_id);
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart;
} else {
// Chart survived reconcile — just update data
_updateTargetFpsChart(target.id, target.state.fps_target || 30);
}
}
});
// Clean up history and charts for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) delete _targetFpsHistory[id];
});
Object.keys(_targetFpsCharts).forEach(id => {
if (!runningIds.has(id)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
});
} catch (error) {
if (error.isAuth) return;