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

@@ -1,18 +1,24 @@
/** /**
* CardSection — collapsible section with name filtering for card grids. * CardSection — collapsible section with name filtering and incremental
* card reconciliation for card grids.
* *
* Usage: * Usage:
* const section = new CardSection('led-devices', { * const section = new CardSection('led-devices', {
* titleKey: 'targets.section.devices', * titleKey: 'targets.section.devices',
* gridClass: 'devices-grid', * gridClass: 'devices-grid',
* addCardOnclick: "showAddDevice()", * addCardOnclick: "showAddDevice()",
* keyAttr: 'data-device-id',
* }); * });
* *
* // In the render function (building innerHTML): * const items = devices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
* html += section.render(cardsHtml, cardCount);
* *
* // After container.innerHTML is set: * if (section.isMounted()) {
* section.reconcile(items); // incremental DOM diff
* } else {
* html += section.render(items); // initial HTML string
* // after container.innerHTML = html:
* section.bind(); * section.bind();
* }
*/ */
import { t } from './i18n.js'; import { t } from './i18n.js';
@@ -32,17 +38,32 @@ export class CardSection {
* @param {string} opts.titleKey i18n key for the section title * @param {string} opts.titleKey i18n key for the section title
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid' * @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid'
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card * @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
*/ */
constructor(sectionKey, { titleKey, gridClass, addCardOnclick }) { constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr }) {
this.sectionKey = sectionKey; this.sectionKey = sectionKey;
this.titleKey = titleKey; this.titleKey = titleKey;
this.gridClass = gridClass; this.gridClass = gridClass;
this.addCardOnclick = addCardOnclick || ''; this.addCardOnclick = addCardOnclick || '';
this.keyAttr = keyAttr || '';
this._filterValue = ''; this._filterValue = '';
this._lastItems = null;
} }
/** Returns section HTML. Call during innerHTML building. */ /** True if this section's DOM element exists (i.e. not the first render). */
render(cardsHtml, count) { isMounted() {
return !!document.querySelector(`[data-card-section="${this.sectionKey}"]`);
}
/**
* Returns section HTML string for initial innerHTML building.
* @param {Array<{key: string, html: string}>} items
*/
render(items) {
this._lastItems = items;
const count = items.length;
const cardsHtml = items.map(i => i.html).join('');
const isCollapsed = !!_getCollapsedMap()[this.sectionKey]; const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
const chevron = isCollapsed ? '\u25B6' : '\u25BC'; const chevron = isCollapsed ? '\u25B6' : '\u25BC';
const contentDisplay = isCollapsed ? ' style="display:none"' : ''; const contentDisplay = isCollapsed ? ' style="display:none"' : '';
@@ -106,6 +127,77 @@ export class CardSection {
this._applyFilter(content, this._filterValue); this._applyFilter(content, this._filterValue);
} }
} }
// Tag card elements with their source HTML for future reconciliation
this._tagCards(content);
}
/**
* Incremental DOM diff — update cards in-place without rebuilding the section.
* @param {Array<{key: string, html: string}>} items
* @returns {{added: Set<string>, replaced: Set<string>, removed: Set<string>}}
*/
reconcile(items) {
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
if (!content) return { added: new Set(), replaced: new Set(), removed: new Set() };
this._lastItems = items;
// Update count badge
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
if (countEl) countEl.textContent = items.length;
const newMap = new Map(items.map(i => [i.key, i.html]));
const addCard = content.querySelector('.cs-add-card');
const added = new Set();
const replaced = new Set();
const removed = new Set();
// Process existing cards: remove or update
if (this.keyAttr) {
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
for (const card of existing) {
const key = card.getAttribute(this.keyAttr);
if (!newMap.has(key)) {
card.remove();
removed.add(key);
} else {
const newHtml = newMap.get(key);
if (card._csHtml !== newHtml) {
const tmp = document.createElement('div');
tmp.innerHTML = newHtml;
const newEl = tmp.firstElementChild;
newEl._csHtml = newHtml;
card.replaceWith(newEl);
replaced.add(key);
}
// else: unchanged — skip
}
}
// Insert new cards (keys not already in DOM)
const existingKeys = new Set([...content.querySelectorAll(`[${this.keyAttr}]`)].map(
el => el.getAttribute(this.keyAttr)
));
for (const { key, html } of items) {
if (!existingKeys.has(key)) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
const newEl = tmp.firstElementChild;
newEl._csHtml = html;
if (addCard) content.insertBefore(newEl, addCard);
else content.appendChild(newEl);
added.add(key);
}
}
}
// Re-apply filter
if (this._filterValue) {
this._applyFilter(content, this._filterValue);
}
return { added, replaced, removed };
} }
/** Bind an array of CardSection instances. */ /** Bind an array of CardSection instances. */
@@ -115,6 +207,16 @@ export class CardSection {
// ── private ── // ── private ──
_tagCards(content) {
if (!this.keyAttr || !this._lastItems) return;
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
cards.forEach(card => {
const key = card.getAttribute(this.keyAttr);
if (htmlMap.has(key)) card._csHtml = htmlMap.get(key);
});
}
_toggleCollapse(header, content) { _toggleCollapse(header, content) {
const map = _getCollapsedMap(); const map = _getCollapsedMap();
const nowCollapsed = !map[this.sectionKey]; const nowCollapsed = !map[this.sectionKey];

View File

@@ -55,8 +55,8 @@ export async function loadProfiles() {
function renderProfiles(profiles, runningTargetIds = new Set()) { function renderProfiles(profiles, runningTargetIds = new Set()) {
const container = document.getElementById('profiles-content'); const container = document.getElementById('profiles-content');
const cardsHtml = profiles.map(p => createProfileCard(p, runningTargetIds)).join(''); const items = profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }));
container.innerHTML = csProfiles.render(cardsHtml, profiles.length); container.innerHTML = csProfiles.render(items);
csProfiles.bind(); csProfiles.bind();
// Localize data-i18n elements within the profiles container only // Localize data-i18n elements within the profiles container only

View File

@@ -667,18 +667,18 @@ function renderPictureSourcesList(streams) {
if (tab.key === 'raw') { if (tab.key === 'raw') {
panelContent = panelContent =
csRawStreams.render(rawStreams.map(renderStreamCard).join(''), rawStreams.length) + csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
csRawTemplates.render(_cachedCaptureTemplates.map(renderCaptureTemplateCard).join(''), _cachedCaptureTemplates.length); csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
} else if (tab.key === 'processed') { } else if (tab.key === 'processed') {
panelContent = panelContent =
csProcStreams.render(processedStreams.map(renderStreamCard).join(''), processedStreams.length) + csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
csProcTemplates.render(_cachedPPTemplates.map(renderPPTemplateCard).join(''), _cachedPPTemplates.length); csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
} else if (tab.key === 'audio') { } else if (tab.key === 'audio') {
panelContent = panelContent =
csAudioMulti.render(multichannelSources.map(renderAudioSourceCard).join(''), multichannelSources.length) + csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
csAudioMono.render(monoSources.map(renderAudioSourceCard).join(''), monoSources.length); csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
} else { } else {
panelContent = csStaticStreams.render(staticImageStreams.map(renderStreamCard).join(''), staticImageStreams.length); panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
} }
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`; return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;

View File

@@ -21,11 +21,11 @@ import { CardSection } from '../core/card-sections.js';
// (pattern-templates.js calls window.loadTargetsTab) // (pattern-templates.js calls window.loadTargetsTab)
// ── Card section instances ── // ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()" }); 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()" }); 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()" }); 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()" }); 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()" }); 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) // Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => { 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 --- // --- Editor state ---
let _editorCssSources = []; // populated when editor opens let _editorCssSources = []; // populated when editor opens
@@ -433,33 +450,47 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import // Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
const devicesHtml = ledDevices.map(device => createDeviceCard(device)).join(''); // Build items arrays for each section
const cssHtml = Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join(''); const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
const ledTargetsHtml = ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join(''); const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap) }));
const kcTargetsHtml = kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join(''); const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap) }));
const patternTmplHtml = patternTemplates.map(pt => createPatternTemplateCard(pt)).join(''); const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap) }));
const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
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 = ` const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led"> <div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
${csDevices.render(devicesHtml, ledDevices.length)} ${csDevices.render(deviceItems)}
${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)} ${csColorStrips.render(cssItems)}
${csLedTargets.render(ledTargetsHtml, ledTargets.length)} ${csLedTargets.render(ledTargetItems)}
</div>`; </div>`;
const kcPanel = ` const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors"> <div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
${csKCTargets.render(kcTargetsHtml, kcTargets.length)} ${csKCTargets.render(kcTargetItems)}
${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)} ${csPatternTemplates.render(patternItems)}
</div>`; </div>`;
container.innerHTML = tabBar + ledPanel + kcPanel; container.innerHTML = tabBar + ledPanel + kcPanel;
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]); CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
}
// Attach event listeners and fetch brightness for device cards // Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => { devicesWithState.forEach(device => {
attachDeviceListeners(device.id); attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) { if ((device.capabilities || []).includes('brightness_control')) {
// Only fetch from device if we don't have a cached value yet
if (device.id in _deviceBrightnessCache) { if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id]; const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${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 => { Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id); if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
}); });
// Destroy old chart instances before DOM rebuild replaces canvases // 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)) { for (const id of Object.keys(_targetFpsCharts)) {
_targetFpsCharts[id].destroy(); _targetFpsCharts[id].destroy();
delete _targetFpsCharts[id]; 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 allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set(); const runningIds = new Set();
allTargets.forEach(target => { allTargets.forEach(target => {
@@ -506,18 +547,30 @@ export async function loadTargetsTab() {
if (target.state.fps_actual != null) { if (target.state.fps_actual != null) {
_pushTargetFps(target.id, target.state.fps_actual); _pushTargetFps(target.id, target.state.fps_actual);
} }
// Create chart if it doesn't exist (new or replaced card)
if (!_targetFpsCharts[target.id]) {
const history = _targetFpsHistory[target.id] || []; const history = _targetFpsHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30; const fpsTarget = target.state.fps_target || 30;
const device = devices.find(d => d.id === target.device_id); 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 maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps); const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart; 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 // Clean up history and charts for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => { Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) delete _targetFpsHistory[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) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;