diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index b681a55..eb735b4 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -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: * const section = new CardSection('led-devices', { * titleKey: 'targets.section.devices', * gridClass: 'devices-grid', * addCardOnclick: "showAddDevice()", + * keyAttr: 'data-device-id', * }); * - * // In the render function (building innerHTML): - * html += section.render(cardsHtml, cardCount); + * const items = devices.map(d => ({ key: d.id, html: createDeviceCard(d) })); * - * // After container.innerHTML is set: - * section.bind(); + * if (section.isMounted()) { + * section.reconcile(items); // incremental DOM diff + * } else { + * html += section.render(items); // initial HTML string + * // after container.innerHTML = html: + * section.bind(); + * } */ 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.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.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.titleKey = titleKey; this.gridClass = gridClass; this.addCardOnclick = addCardOnclick || ''; + this.keyAttr = keyAttr || ''; this._filterValue = ''; + this._lastItems = null; } - /** Returns section HTML. Call during innerHTML building. */ - render(cardsHtml, count) { + /** True if this section's DOM element exists (i.e. not the first render). */ + 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 chevron = isCollapsed ? '\u25B6' : '\u25BC'; const contentDisplay = isCollapsed ? ' style="display:none"' : ''; @@ -106,6 +127,77 @@ export class CardSection { 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, replaced: Set, removed: Set}} + */ + 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. */ @@ -115,6 +207,16 @@ export class CardSection { // ── 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) { const map = _getCollapsedMap(); const nowCollapsed = !map[this.sectionKey]; diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index 430259b..f98b773 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -55,8 +55,8 @@ export async function loadProfiles() { function renderProfiles(profiles, runningTargetIds = new Set()) { const container = document.getElementById('profiles-content'); - const cardsHtml = profiles.map(p => createProfileCard(p, runningTargetIds)).join(''); - container.innerHTML = csProfiles.render(cardsHtml, profiles.length); + const items = profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })); + container.innerHTML = csProfiles.render(items); csProfiles.bind(); // Localize data-i18n elements within the profiles container only diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index c0317f4..c4dedcf 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -667,18 +667,18 @@ function renderPictureSourcesList(streams) { if (tab.key === 'raw') { panelContent = - csRawStreams.render(rawStreams.map(renderStreamCard).join(''), rawStreams.length) + - csRawTemplates.render(_cachedCaptureTemplates.map(renderCaptureTemplateCard).join(''), _cachedCaptureTemplates.length); + csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + + csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))); } else if (tab.key === 'processed') { panelContent = - csProcStreams.render(processedStreams.map(renderStreamCard).join(''), processedStreams.length) + - csProcTemplates.render(_cachedPPTemplates.map(renderPPTemplateCard).join(''), _cachedPPTemplates.length); + csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + + csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))); } else if (tab.key === 'audio') { panelContent = - csAudioMulti.render(multichannelSources.map(renderAudioSourceCard).join(''), multichannelSources.length) + - csAudioMono.render(monoSources.map(renderAudioSourceCard).join(''), monoSources.length); + csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + + csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); } else { - panelContent = csStaticStreams.render(staticImageStreams.map(renderStreamCard).join(''), staticImageStreams.length); + panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); } return `
${panelContent}
`; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 637ad3d..2ede722 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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 = ` -
- ${csDevices.render(devicesHtml, ledDevices.length)} - ${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)} - ${csLedTargets.render(ledTargetsHtml, ledTargets.length)} -
`; + // Track which target cards were replaced/added (need chart re-init) + let changedTargetIds = null; - const kcPanel = ` -
- ${csKCTargets.render(kcTargetsHtml, kcTargets.length)} - ${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)} -
`; - - 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 = ` +
+ ${csDevices.render(deviceItems)} + ${csColorStrips.render(cssItems)} + ${csLedTargets.render(ledTargetItems)} +
`; + const kcPanel = ` +
+ ${csKCTargets.render(kcTargetItems)} + ${csPatternTemplates.render(patternItems)} +
`; + 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;