/** * Targets tab — combined view of devices, LED targets, KC targets, pattern templates. */ import { apiKey, _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, kcWebSockets, ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, streamsCache, audioSourcesCache, syncClocksCache, colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.js'; import { _splitOpenrgbZone } from './device-discovery.js'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, } from '../core/icons.js'; import { EntitySelect } from '../core/entity-palette.js'; import { IconSelect } from '../core/icon-select.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { createFpsSparkline } from '../core/chart-utils.js'; import { CardSection } from '../core/card-sections.js'; import { TreeNav } from '../core/tree-nav.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) // ── Card section instances ── const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `` }); const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `` }); const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' }); // Re-render targets tab when language changes (only if tab is active) document.addEventListener('languageChanged', () => { if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab(); }); // --- FPS sparkline history and chart instances for target cards --- const _TARGET_MAX_FPS_SAMPLES = 30; const _targetFpsHistory = {}; // fps_actual (rolling avg) const _targetFpsCurrentHistory = {}; // fps_current (sends/sec) const _targetFpsCharts = {}; function _pushTargetFps(targetId, actual, current) { if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = []; const h = _targetFpsHistory[targetId]; h.push(actual); if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); if (!_targetFpsCurrentHistory[targetId]) _targetFpsCurrentHistory[targetId] = []; const c = _targetFpsCurrentHistory[targetId]; c.push(current); if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift(); } function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) { return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps }); } function _updateTargetFpsChart(targetId, fpsTarget) { const chart = _targetFpsCharts[targetId]; if (!chart) return; const actualH = _targetFpsHistory[targetId] || []; const currentH = _targetFpsCurrentHistory[targetId] || []; // Mutate in-place to avoid array copies const ds0 = chart.data.datasets[0].data; ds0.length = 0; ds0.push(...actualH); const ds1 = chart.data.datasets[1].data; ds1.length = 0; ds1.push(...currentH); while (chart.data.labels.length < ds0.length) chart.data.labels.push(''); chart.data.labels.length = ds0.length; chart.options.scales.y.max = fpsTarget * 1.15; chart.update('none'); } // --- Editor state --- let _editorCssSources = []; // populated when editor opens let _targetTagsInput = null; class TargetEditorModal extends Modal { constructor() { super('target-editor-modal'); } snapshotValues() { return { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, protocol: document.getElementById('target-editor-protocol').value, css_source: document.getElementById('target-editor-css-source').value, brightness_vs: document.getElementById('target-editor-brightness-vs').value, brightness_threshold: document.getElementById('target-editor-brightness-threshold').value, fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked, tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), }; } } const targetEditorModal = new TargetEditorModal(); function _protocolBadge(device, target) { const dt = device?.device_type; if (!dt || dt === 'wled') { const proto = target.protocol === 'http' ? 'HTTP' : 'DDP'; return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`; } const map = { openrgb: [ICON_PALETTE, 'OpenRGB SDK'], adalight: [ICON_PLUG, t('targets.protocol.serial')], ambiled: [ICON_PLUG, t('targets.protocol.serial')], mqtt: [ICON_GLOBE, 'MQTT'], ws: [ICON_GLOBE, 'WebSocket'], mock: [ICON_WRENCH, 'Mock'], }; const [icon, label] = map[dt] || [ICON_PLUG, dt]; return `${icon} ${label}`; } let _targetNameManuallyEdited = false; function _autoGenerateTargetName() { if (_targetNameManuallyEdited) return; if (document.getElementById('target-editor-id').value) return; const deviceSelect = document.getElementById('target-editor-device'); const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; const cssSelect = document.getElementById('target-editor-css-source'); const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || ''; if (!deviceName || !cssName) return; document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } function _updateFpsRecommendation() { const el = document.getElementById('target-editor-fps-rec'); const deviceSelect = document.getElementById('target-editor-device'); const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); if (!device || !device.led_count) { el.style.display = 'none'; return; } const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type); if (fps !== null) { el.textContent = t('targets.fps.rec', { fps, leds: device.led_count }); el.style.display = ''; } else { el.style.display = 'none'; } } function _updateDeviceInfo() { const deviceSelect = document.getElementById('target-editor-device'); const el = document.getElementById('target-editor-device-info'); const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); if (device && device.led_count) { el.textContent = `${device.led_count} LEDs`; el.style.display = ''; } else { el.style.display = 'none'; } } function _updateKeepaliveVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const keepaliveGroup = document.getElementById('target-editor-keepalive-group'); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const caps = selectedDevice?.capabilities || []; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; } function _updateSpecificSettingsVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const isWled = !selectedDevice || selectedDevice.device_type === 'wled'; // Hide WLED-only controls (protocol + keepalive) for non-WLED devices const protocolGroup = document.getElementById('target-editor-protocol-group'); if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none'; // keepalive is controlled further by _updateKeepaliveVisibility const keepaliveGroup = document.getElementById('target-editor-keepalive-group'); if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none'; } function _updateBrightnessThresholdVisibility() { // Always visible — threshold considers both brightness source and pixel content document.getElementById('target-editor-brightness-threshold-group').style.display = ''; } // ── EntitySelect instances for target editor ── let _deviceEntitySelect = null; let _cssEntitySelect = null; let _brightnessVsEntitySelect = null; let _protocolIconSelect = null; function _populateCssDropdown(selectedId = '') { const select = document.getElementById('target-editor-css-source'); select.innerHTML = _editorCssSources.map(s => `` ).join(''); } function _populateBrightnessVsDropdown(selectedId = '') { const select = document.getElementById('target-editor-brightness-vs'); let html = ``; _cachedValueSources.forEach(vs => { html += ``; }); select.innerHTML = html; } function _ensureTargetEntitySelects() { // Device if (_deviceEntitySelect) _deviceEntitySelect.destroy(); _deviceEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-device'), getItems: () => _targetEditorDevices.map(d => ({ value: d.id, label: d.name, icon: getDeviceTypeIcon(d.device_type), desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''), })), placeholder: t('palette.search'), }); // CSS source if (_cssEntitySelect) _cssEntitySelect.destroy(); _cssEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-css-source'), getItems: () => _editorCssSources.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type, })), placeholder: t('palette.search'), }); // Brightness value source if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-brightness-vs'), getItems: () => _cachedValueSources.map(vs => ({ value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, })), placeholder: t('palette.search'), allowNone: true, noneLabel: t('targets.brightness_vs.none'), }); } const _pIcon = (d) => ``; function _ensureProtocolIconSelect() { const sel = document.getElementById('target-editor-protocol'); if (!sel) return; const items = [ { value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') }, { value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') }, ]; if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; } _protocolIconSelect = new IconSelect({ target: sel, items, columns: 2 }); } export async function showTargetEditor(targetId = null, cloneData = null) { try { // Load devices, CSS sources, and value sources for dropdowns const [devices, cssSources] = await Promise.all([ devicesCache.fetch().catch(() => []), colorStripSourcesCache.fetch().catch(() => []), valueSourcesCache.fetch(), ]); set_targetEditorDevices(devices); _editorCssSources = cssSources; // Populate device select const deviceSelect = document.getElementById('target-editor-device'); deviceSelect.innerHTML = ''; devices.forEach(d => { const opt = document.createElement('option'); opt.value = d.id; opt.dataset.name = d.name; const shortUrl = d.url && d.url.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : ''; const devType = (d.device_type || 'wled').toUpperCase(); opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; deviceSelect.appendChild(opt); }); let _editorTags = []; if (targetId) { // Editing existing target const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); _editorTags = target.tags || []; document.getElementById('target-editor-id').value = target.id; document.getElementById('target-editor-name').value = target.name; deviceSelect.value = target.device_id || ''; const fps = target.fps ?? 30; document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`; const thresh = target.min_brightness_threshold ?? 0; document.getElementById('target-editor-brightness-threshold').value = thresh; document.getElementById('target-editor-brightness-threshold-value').textContent = thresh; document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false; document.getElementById('target-editor-protocol').value = target.protocol || 'ddp'; _populateCssDropdown(target.color_strip_source_id || ''); _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { // Cloning — create mode but pre-filled from clone data _editorTags = cloneData.tags || []; document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)'; deviceSelect.value = cloneData.device_id || ''; const fps = cloneData.fps ?? 30; document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; const cloneThresh = cloneData.min_brightness_threshold ?? 0; document.getElementById('target-editor-brightness-threshold').value = cloneThresh; document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh; document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false; document.getElementById('target-editor-protocol').value = cloneData.protocol || 'ddp'; _populateCssDropdown(cloneData.color_strip_source_id || ''); _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); } else { // Creating new target document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = ''; document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-keepalive-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; document.getElementById('target-editor-brightness-threshold').value = 0; document.getElementById('target-editor-brightness-threshold-value').textContent = '0'; document.getElementById('target-editor-adaptive-fps').checked = false; document.getElementById('target-editor-protocol').value = 'ddp'; _populateCssDropdown(''); _populateBrightnessVsDropdown(''); } // Entity palette selectors _ensureTargetEntitySelects(); _ensureProtocolIconSelect(); if (_protocolIconSelect) _protocolIconSelect.setValue(document.getElementById('target-editor-protocol').value); // Auto-name generation _targetNameManuallyEdited = !!(targetId || cloneData); document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); }; document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); }; if (!targetId && !cloneData) _autoGenerateTargetName(); // Show/hide conditional fields _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _updateBrightnessThresholdVisibility(); // Tags if (_targetTagsInput) _targetTagsInput.destroy(); _targetTagsInput = new TagInput(document.getElementById('target-tags-container'), { placeholder: window.t ? t('tags.placeholder') : 'Add tag...' }); _targetTagsInput.setValue(_editorTags); targetEditorModal.snapshot(); targetEditorModal.open(); document.getElementById('target-editor-error').style.display = 'none'; setTimeout(() => desktopFocus(document.getElementById('target-editor-name')), 100); } catch (error) { console.error('Failed to open target editor:', error); showToast(t('target.error.editor_open_failed'), 'error'); } } export function isTargetEditorDirty() { return targetEditorModal.isDirty(); } export async function closeTargetEditorModal() { await targetEditorModal.close(); } export function forceCloseTargetEditorModal() { if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } targetEditorModal.forceClose(); } export async function saveTargetEditor() { const targetId = document.getElementById('target-editor-id').value; const name = document.getElementById('target-editor-name').value.trim(); const deviceId = document.getElementById('target-editor-device').value; const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value); if (!name) { targetEditorModal.showError(t('targets.error.name_required')); return; } const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const colorStripSourceId = document.getElementById('target-editor-css-source').value; const brightnessVsId = document.getElementById('target-editor-brightness-vs').value; const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0; const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked; const protocol = document.getElementById('target-editor-protocol').value; const payload = { name, device_id: deviceId, color_strip_source_id: colorStripSourceId, brightness_value_source_id: brightnessVsId, min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, adaptive_fps: adaptiveFps, protocol, tags: _targetTagsInput ? _targetTagsInput.getValue() : [], }; try { let response; if (targetId) { response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { payload.target_type = 'led'; response = await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload), }); } if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Failed to save'); } showToast(targetId ? t('targets.updated') : t('targets.created'), 'success'); outputTargetsCache.invalidate(); targetEditorModal.forceClose(); await loadTargetsTab(); } catch (error) { if (error.isAuth) return; console.error('Error saving target:', error); targetEditorModal.showError(error.message); } } // ===== TARGETS TAB (WLED devices + targets combined) ===== let _treeTriggered = false; const _targetsTree = new TreeNav('targets-tree-nav', { onSelect: (key) => { _treeTriggered = true; switchTargetSubTab(key); _treeTriggered = false; } }); export function switchTargetSubTab(tabKey) { document.querySelectorAll('.target-sub-tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`) ); localStorage.setItem('activeTargetSubTab', tabKey); updateSubTabHash('targets', tabKey); // Update tree active state (unless the tree triggered this switch) if (!_treeTriggered) { _targetsTree.setActive(tabKey); } } const _targetSectionMap = { 'led-devices': [csDevices], 'led-targets': [csLedTargets], 'kc-targets': [csKCTargets], 'kc-patterns': [csPatternTemplates], }; export function expandAllTargetSections() { const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices'; CardSection.expandAll(_targetSectionMap[activeSubTab] || []); } export function collapseAllTargetSections() { const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices'; CardSection.collapseAll(_targetSectionMap[activeSubTab] || []); } let _loadTargetsLock = false; let _actionInFlight = false; export async function loadTargetsTab() { const container = document.getElementById('targets-panel-content'); if (!container) return; // Skip if another loadTargetsTab or a button action is already running if (_loadTargetsLock || _actionInFlight) return; _loadTargetsLock = true; if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); try { // Fetch all entities via DataCache const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([ devicesCache.fetch().catch(() => []), outputTargetsCache.fetch().catch(() => []), colorStripSourcesCache.fetch().catch(() => []), patternTemplatesCache.fetch().catch(() => []), streamsCache.fetch().catch(() => []), valueSourcesCache.fetch().catch(() => []), audioSourcesCache.fetch().catch(() => []), syncClocksCache.fetch().catch(() => []), ]); const colorStripSourceMap = {}; cssArr.forEach(s => { colorStripSourceMap[s.id] = s; }); let pictureSourceMap = {}; psArr.forEach(s => { pictureSourceMap[s.id] = s; }); let patternTemplateMap = {}; patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); let valueSourceMap = {}; valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); let audioSourceMap = {}; asSrcArr.forEach(s => { audioSourceMap[s.id] = s; }); // Fetch all device states, target states, and target metrics in batch const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([ fetchWithAuth('/devices/batch/states'), fetchWithAuth('/output-targets/batch/states'), fetchWithAuth('/output-targets/batch/metrics'), ]); const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {}; const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {}; const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {}; const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} })); // Enrich targets with state/metrics; fetch colors only for running KC targets const targetsWithState = await Promise.all( targets.map(async (target) => { const state = allTargetStates[target.id] || {}; const metrics = allTargetMetrics[target.id] || {}; let latestColors = null; if (target.target_type === 'key_colors' && state.processing) { try { const colorsResp = await fetch(`${API_BASE}/output-targets/${target.id}/colors`, { headers: getHeaders() }); if (colorsResp.ok) latestColors = await colorsResp.json(); } catch {} } return { ...target, state, metrics, latestColors }; }) ); // Build device map for target name resolution const deviceMap = {}; devicesWithState.forEach(d => { deviceMap[d.id] = d; }); // Group by type const ledDevices = devicesWithState; const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); // Update tab badge with running target count const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length; updateTabBadge('targets', runningCount); const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; // Build tree navigation structure const treeGroups = [ { key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', children: [ { key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length }, { key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length }, ] }, { key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors', children: [ { key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length }, { key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length }, ] } ]; // Determine which tree leaf is active — migrate old values const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns']; const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices'; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); // Build items arrays for each section (apply saved drag order) const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }))); const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) }))); const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) }))); const patternItems = csPatternTemplates.applySortOrder(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 ── _targetsTree.updateCounts({ 'led-devices': ledDevices.length, 'led-targets': ledTargets.length, 'kc-targets': kcTargets.length, 'kc-patterns': patternTemplates.length, }); csDevices.reconcile(deviceItems); 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]); // Re-render cached LED preview frames onto new canvas elements after reconciliation for (const id of ledResult.replaced) { const frame = _ledPreviewLastFrame[id]; if (frame && ledPreviewWebSockets[id]) { const canvas = document.getElementById(`led-preview-canvas-${id}`); if (canvas) _renderLedStrip(canvas, frame); } } } else { // ── First render: build full HTML ── const panels = [ { key: 'led-devices', html: csDevices.render(deviceItems) }, { key: 'led-targets', html: csLedTargets.render(ledTargetItems) }, { key: 'kc-targets', html: csKCTargets.render(kcTargetItems) }, { key: 'kc-patterns', html: csPatternTemplates.render(patternItems) }, ].map(p => `