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:
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user