/** * ═══════════════════════════════════════════════════════ * AI FORECAST MULTI-CHART WORKSPACE ENGINE (V1) * Provides: PaneState, ChartPaneController, StreamManager, * DataCoordinator, WorkspaceController * ═══════════════════════════════════════════════════════ */ /* ── Constants ────────────────────────────────────── */ const WORKSPACE_STORAGE_KEY = 'aiforecast_workspace'; const CHART_HISTORY_LIMIT_WS = 500; const MAX_CONCURRENT_FETCHES = 4; const WS_RECONNECT_DELAY = 5000; const LAYOUT_PRESETS = [1, 2, 4, 8]; const MODEL_CONFIG = window.AIFORECAST_MODEL_CONFIG; if (!MODEL_CONFIG) { throw new Error('AIFORECAST_MODEL_CONFIG is missing'); } const LAYOUT_GRID_MAP = { 1: { cols: 1, rows: 1 }, 2: { cols: 2, rows: 1 }, 4: { cols: 2, rows: 2 }, 8: { cols: 4, rows: 2 }, }; const FORECAST_MODEL_ORDER = MODEL_CONFIG.modelOrder; const DEFAULT_AI_MODEL_SELECTION = MODEL_CONFIG.defaultSelection; function normalizeAiModelSelection(selection = null) { return MODEL_CONFIG.normalizeSelection(selection); } function getAiModelSignature(selection = null) { return MODEL_CONFIG.signature(selection); } function buildWebSocketUrl(apiBase, path) { const fallbackOrigin = ( window.location.origin && window.location.origin !== 'null' ) ? window.location.origin : 'http://127.0.0.1'; const resolvedUrl = new URL(path, apiBase || fallbackOrigin); resolvedUrl.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return resolvedUrl.toString(); } /* ══════════════════════════════════════════════════════ PaneState — Per-pane data container ══════════════════════════════════════════════════════ */ class PaneState { constructor(id, symbol = 'XAUUSD', interval = '1d') { this.paneId = id; this.symbol = symbol; this.interval = interval; this.indicatorMode = 'none'; this.horizon = 10; this.aiModels = normalizeAiModelSelection(); // Chart instances (set by ChartPaneController) this.chartInstance = null; this.candleSeries = null; this.forecastSeries = { candles: null, reserve: null, p50: null, p10: null, p90: null, segments: [], models: Object.fromEntries(FORECAST_MODEL_ORDER.map((modelKey) => [modelKey, null])), }; this.indicatorSeries = { bbUpper: null, bbMid: null, bbLower: null, rsi: null }; this.forecastHoverEl = null; // Network this.fetchController = null; this.analysisFetchController = null; this.analysisRequestPromise = null; this.analysisRequestKey = null; this.analysisRetryTimer = null; // Data this.lastCandleData = null; this.lastAnalysis = { payload: null, symbol: null, interval: null, horizon: null, modelSignature: null, complete: false, }; this.analysisPayloadCache = {}; this.partialModelPayloads = {}; this.analysisProgress = { requested: [], ready: [], pending: [], failed: [] }; this.cachedForecastModelLines = MODEL_CONFIG.createEmptyLines(); this.chartContext = { symbol: null, interval: null }; this.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false }; this.priceFormat = { precision: 2, minMove: 0.01 }; this.historicalBarCount = 0; this.loadedFutureBars = 0; // UI this.loading = false; this.error = null; // DOM refs (set during mount) this.containerEl = null; this.chartEl = null; this.loaderEl = null; this.gaugesEl = null; this.paneHeaderEl = null; this.priceEl = null; this.analysisButtonEl = null; this.analysisOverlayEl = null; this.analysisOpen = false; } hasMatchingAnalysis() { return Boolean( this.lastAnalysis && this.lastAnalysis.payload && this.lastAnalysis.complete === true && this.lastAnalysis.symbol === this.symbol && this.lastAnalysis.interval === this.interval && this.lastAnalysis.horizon === this.horizon && this.lastAnalysis.modelSignature === this.getAiModelSignature() ); } hasMatchingForecast() { return Boolean( this.forecastContext && this.forecastContext.ready && this.forecastContext.symbol === this.symbol && this.forecastContext.interval === this.interval && this.forecastContext.horizon === this.horizon && this.forecastContext.modelSignature === this.getAiModelSignature() ); } getAiModelSelection() { return normalizeAiModelSelection(this.aiModels); } getAiModelSignature() { return getAiModelSignature(this.aiModels); } async fetchAI(options = {}) { const horizon = this.horizon || 24; const requestSymbol = this.symbol; const requestInterval = this.interval; const requestHorizon = horizon; const requestModels = this.getAiModelSelection(); const requestModelSignature = getAiModelSignature(requestModels); const requestKey = `${requestSymbol}|${requestInterval}|${requestHorizon}|${requestModelSignature}`; const enabledModelKeys = FORECAST_MODEL_ORDER.filter((modelKey) => requestModels[modelKey]); if (!options.force && this.hasMatchingAnalysis()) { return this.lastAnalysis.payload; } if (!options.force && this.analysisRequestPromise && this.analysisRequestKey === requestKey) { return this.analysisRequestPromise; } if (this.analysisRetryTimer) { clearTimeout(this.analysisRetryTimer); this.analysisRetryTimer = null; } if (this.analysisFetchController) { this.analysisFetchController.abort(); } if (!options.preserveForecastVisuals) { if (typeof window.clearPaneForecastCandlesOnly === 'function') { window.clearPaneForecastCandlesOnly(this); } else if (this.forecastSeries?.candles?.setData) { this.forecastSeries.candles.setData([]); if (this.forecastSeries.candles.applyOptions) { this.forecastSeries.candles.applyOptions({ visible: false }); } } } const controller = new AbortController(); this.analysisFetchController = controller; this.analysisRequestKey = requestKey; this.error = null; this.partialModelPayloads = {}; this.analysisProgress = { requested: [...enabledModelKeys], ready: [], pending: [...enabledModelKeys], failed: [], }; this.lastAnalysis = { payload: null, symbol: requestSymbol, interval: requestInterval, horizon: requestHorizon, modelSignature: requestModelSignature, complete: false, }; if (typeof window.renderPaneAnalysisUI === 'function') { window.renderPaneAnalysisUI(this); } const shouldRenderPaneGauges = !(window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0'); if (this.gaugesEl && shouldRenderPaneGauges && !this.hasMatchingAnalysis()) { this.gaugesEl.innerHTML = '
'; } const requestPromise = (async () => { const successfulPayloads = {}; const failedModels = []; const removePendingModel = (modelKey) => { this.analysisProgress.pending = this.analysisProgress.pending.filter((key) => key !== modelKey); }; const isRequestStale = () => ( controller.signal.aborted || this.symbol !== requestSymbol || this.interval !== requestInterval || (this.horizon || 24) !== requestHorizon || this.getAiModelSignature() !== requestModelSignature ); const applyProgressivePayload = (isComplete = false) => { if (typeof window.buildCombinedForecastPayloadForPane !== 'function') { return null; } const payload = window.buildCombinedForecastPayloadForPane( requestModels, successfulPayloads, { pending: this.analysisProgress.pending, failed: failedModels, }, ); if (!payload || typeof window.applyForecastPayloadToPane !== 'function') { return payload; } window.applyForecastPayloadToPane( this, payload, { symbol: requestSymbol, interval: requestInterval, horizon: requestHorizon, models: requestModels, modelSignature: requestModelSignature, complete: isComplete, }, ); return payload; }; try { const modelResults = await Promise.allSettled( enabledModelKeys.map(async (modelKey) => { const modelPayload = await DataCoordinator.fetchForecastModel( requestSymbol, requestInterval, requestHorizon, modelKey, controller.signal, ); if (isRequestStale()) { return null; } successfulPayloads[modelKey] = modelPayload; this.partialModelPayloads = { ...successfulPayloads }; if (!this.analysisProgress.ready.includes(modelKey)) { this.analysisProgress.ready = [...this.analysisProgress.ready, modelKey]; } removePendingModel(modelKey); return applyProgressivePayload(false); }), ); if (isRequestStale()) { return null; } modelResults.forEach((result, index) => { const modelKey = enabledModelKeys[index]; if (result.status === 'fulfilled') { return; } if (result.reason?.name === 'AbortError') { return; } failedModels.push(modelKey); removePendingModel(modelKey); console.error(`[Pane ${this.paneId}] ${modelKey} forecast error:`, result.reason); }); this.analysisProgress.failed = [...failedModels]; const finalPayload = applyProgressivePayload( failedModels.length === 0 && this.analysisProgress.ready.length === enabledModelKeys.length, ); if (!this.analysisProgress.ready.length || !finalPayload) { const failureMessage = failedModels.length ? `Khong co AI model nao tra ket qua: ${failedModels.join(', ')}` : 'Khong co AI model nao tra ket qua'; throw new Error(failureMessage); } if (failedModels.length) { this.error = `Dang thieu du lieu: ${failedModels.join(', ')}`; if (!this.analysisRetryTimer) { this.analysisRetryTimer = setTimeout(() => { this.analysisRetryTimer = null; this.fetchAI({ force: true, preserveForecastVisuals: true }); }, 15000); } } return finalPayload; } catch (e) { if (e.name === 'AbortError') return null; console.error(`[Pane ${this.paneId}] AI fetch error:`, e); this.error = e.message || String(e); if (typeof window.renderPaneAnalysisUI === 'function') { window.renderPaneAnalysisUI(this); } this.analysisRetryTimer = setTimeout(() => { this.analysisRetryTimer = null; this.fetchAI({ force: true }); }, 15000); return null; } finally { if (this.analysisFetchController === controller) { this.analysisFetchController = null; } } })(); this.analysisRequestPromise = requestPromise; try { return await requestPromise; } finally { if (this.analysisRequestPromise === requestPromise) { this.analysisRequestPromise = null; this.analysisRequestKey = null; } } } renderGauges() { if (window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0') { if (this.gaugesEl) { this.gaugesEl.innerHTML = ''; this.gaugesEl.style.display = 'none'; } return; } if (!this.gaugesEl || !this.lastAnalysis || !this.lastAnalysis.payload || !this.lastAnalysis.payload.analysis) { if (this.gaugesEl) this.gaugesEl.innerHTML = ''; return; } this.gaugesEl.style.display = ''; const analysis = this.lastAnalysis.payload.analysis; const technical = analysis.dashboard?.technical || analysis.technicals || {}; const ai = analysis.dashboard?.ai || analysis.ai_gauge || {}; const summary = analysis.dashboard?.summary || analysis.summary || {}; const verdict = this.lastAnalysis.payload.verdict || summary.signal || '--'; const tone = (score) => score > 60 ? 'bull' : score < 40 ? 'bear' : 'flat'; this.gaugesEl.innerHTML = `
${tScore > 50 ? '↑' : '↓'}
S
`; } toJSON() { return { id: this.paneId, symbol: this.symbol, interval: this.interval, indicator: this.indicatorMode, horizon: this.horizon, aiModels: this.getAiModelSelection(), }; } } /* ══════════════════════════════════════════════════════ StreamManager — WebSocket lifecycle & reuse ══════════════════════════════════════════════════════ */ const StreamManager = { _streams: new Map(), // key → { ws, callbacks: Map, symbol, interval } _paneKeys: new Map(), // paneId → key _makeKey(symbol, interval) { return `${symbol}|${interval}`; }, subscribe(paneId, symbol, interval, onMessage) { const key = this._makeKey(symbol, interval); const currentKey = this._paneKeys.get(paneId); if (currentKey === key && this._streams.has(key)) { const existingEntry = this._streams.get(key); existingEntry.callbacks.set(paneId, onMessage); return existingEntry.ws; } this.unsubscribe(paneId); this._paneKeys.set(paneId, key); if (this._streams.has(key)) { const entry = this._streams.get(key); entry.callbacks.set(paneId, onMessage); console.log(`[StreamManager] Reusing WS for ${key}, pane ${paneId} (total: ${entry.callbacks.size})`); return entry.ws; } const apiBase = window.__AIFORECAST_API_BASE || ''; const wsUrl = buildWebSocketUrl( apiBase, `/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`, ); console.log(`[StreamManager] New WS: ${wsUrl} (pane ${paneId})`); const ws = new WebSocket(wsUrl); const entry = { ws, callbacks: new Map([[paneId, onMessage]]), symbol, interval, }; this._streams.set(key, entry); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.error) return; if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', ts: Date.now() })); return; } for (const [, cb] of entry.callbacks) { try { cb(data); } catch (e) { console.warn('[StreamManager] cb error', e); } } } catch (e) { console.warn('[StreamManager] parse error', e); } }; ws.onclose = () => { console.log(`[StreamManager] WS closed: ${key}`); if (this._streams.get(key)?.ws === ws) { this._streams.delete(key); // Reconnect for remaining subscribers after delay const remainingCallbacks = new Map(entry.callbacks); if (remainingCallbacks.size > 0) { setTimeout(() => { for (const [pid, cb] of remainingCallbacks) { const pane = Workspace.getPane(pid); if (pane && pane.symbol === symbol && pane.interval === interval) { this.subscribe(pid, symbol, interval, cb); } } }, WS_RECONNECT_DELAY); } } }; ws.onerror = (e) => { console.warn(`[StreamManager] WS error: ${key}`, e); }; return ws; }, unsubscribe(paneId) { const key = this._paneKeys.get(paneId); if (!key) return; this._paneKeys.delete(paneId); const entry = this._streams.get(key); if (!entry) return; entry.callbacks.delete(paneId); if (entry.callbacks.size === 0) { try { entry.ws.close(); } catch (_) {} this._streams.delete(key); console.log(`[StreamManager] Closed WS ${key} (no subscribers)`); } }, unsubscribeAll() { for (const [key, entry] of this._streams) { try { entry.ws.close(); } catch (_) {} } this._streams.clear(); this._paneKeys.clear(); }, getActiveCount() { return this._streams.size; } }; /* ══════════════════════════════════════════════════════ DataCoordinator — Request dedup & concurrency ══════════════════════════════════════════════════════ */ const DataCoordinator = { _inflightCache: new Map(), _concurrency: 0, _queue: [], async _throttled(fn) { if (this._concurrency >= MAX_CONCURRENT_FETCHES) { await new Promise(resolve => this._queue.push(resolve)); } this._concurrency++; try { return await fn(); } finally { this._concurrency--; if (this._queue.length > 0) this._queue.shift()(); } }, async fetch(path, signal) { const cacheKey = path.split('&_t=')[0]; // strip cache buster for dedup if (this._inflightCache.has(cacheKey)) { return this._inflightCache.get(cacheKey); } const promise = this._throttled(() => { if (typeof apiRequest === 'function') { return apiRequest(path, { signal }); } // Fallback if apiRequest not yet defined const apiBase = window.__AIFORECAST_API_BASE || ''; const sep = path.includes('?') ? '&' : '?'; const url = `${apiBase}${path}${sep}_t=${Date.now()}`; return fetch(url, { signal }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }); }); this._inflightCache.set(cacheKey, promise); try { return await promise; } finally { this._inflightCache.delete(cacheKey); } }, async fetchHistorical(symbol, interval, limit, signal) { return this.fetch( `/api/historical/${encodeURIComponent(symbol)}?interval=${interval}&limit=${limit}`, signal ); }, async fetchIndicators(symbol, interval, limit, signal) { return this.fetch( `/api/indicators/${encodeURIComponent(symbol)}?interval=${interval}&limit=${limit}`, signal ); }, async fetchForecast(symbol, interval, horizon, aiModels, signal) { const normalizedModels = normalizeAiModelSelection(aiModels); return this.fetch( `/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}&use_kronos=${normalizedModels.kronos}&use_timesfm=${normalizedModels.timesfm}&use_chronos=${normalizedModels.chronos}`, signal ); }, async fetchForecastModel(symbol, interval, horizon, modelKey, signal) { const selection = FORECAST_MODEL_ORDER.reduce((accumulator, key) => { accumulator[key] = key === modelKey; return accumulator; }, {}); return this.fetchForecast(symbol, interval, horizon, selection, signal); } }; /* ══════════════════════════════════════════════════════ WorkspaceController — Layout & pane orchestration ══════════════════════════════════════════════════════ */ const Workspace = { layoutPreset: 1, activePaneId: 'pane-0', panes: new Map(), _gridEl: null, _onActivePaneChange: null, // callback(paneId) _onPaneSymbolChange: null, // callback(paneId, symbol, interval) /* ── Init ─────────────────────────────────── */ init(gridEl) { this._gridEl = gridEl || document.getElementById('workspaceGrid'); }, /* ── Pane CRUD ─────────────────────────────── */ createPane(id, symbol, interval) { const pane = new PaneState(id, symbol, interval); this.panes.set(id, pane); return pane; }, getPane(id) { return this.panes.get(id) || null; }, getActivePane() { return this.panes.get(this.activePaneId) || null; }, destroyPane(id) { const pane = this.panes.get(id); if (!pane) return; // Cleanup network StreamManager.unsubscribe(id); if (pane.fetchController) { pane.fetchController.abort(); pane.fetchController = null; } if (pane.analysisFetchController) { pane.analysisFetchController.abort(); pane.analysisFetchController = null; } if (pane.analysisRetryTimer) { clearTimeout(pane.analysisRetryTimer); pane.analysisRetryTimer = null; } // Cleanup chart if (pane.chartInstance) { try { pane.chartInstance.remove(); } catch (_) {} pane.chartInstance = null; } // Cleanup DOM if (pane.containerEl && pane.containerEl.parentNode) { pane.containerEl.parentNode.removeChild(pane.containerEl); } this.panes.delete(id); }, /* ── Active pane ───────────────────────────── */ setActivePane(id) { if (!this.panes.has(id)) return; const prevId = this.activePaneId; this.activePaneId = id; // Update highlight if (this._gridEl) { this._gridEl.querySelectorAll('.chart-pane').forEach(el => { el.classList.toggle('active', el.dataset.paneId === id); }); } if (prevId !== id && this._onActivePaneChange) { this._onActivePaneChange(id); } // Sync AI UI if (window.renderAnalysisPanel && window.renderCompactGauges) { const pane = this.panes.get(id); if (pane && pane.lastAnalysis && pane.lastAnalysis.payload) { window.renderAnalysisPanel(pane.symbol, pane.interval, pane.lastAnalysis.payload); window.renderCompactGauges(pane.symbol, pane.interval, pane.lastAnalysis.payload); if (window.updateDashboardScale) window.updateDashboardScale(); } else { const panel = document.getElementById('analysisPanel'); if (panel) panel.innerHTML = ''; const gContainer = document.getElementById('chartGauges'); if (gContainer) { gContainer.innerHTML = ''; gContainer.classList.remove('combo-active'); } } // re-render mini gauges for all for (const [pId, p] of this.panes) { if (p.renderGauges) p.renderGauges(); } } }, /* ── Layout ────────────────────────────────── */ setLayout(preset) { if (!LAYOUT_PRESETS.includes(preset)) return; const prevPreset = this.layoutPreset; this.layoutPreset = preset; // Determine which panes to keep, create, or destroy const targetCount = preset; const currentIds = Array.from(this.panes.keys()); // Create new panes if needed for (let i = currentIds.length; i < targetCount; i++) { const id = `pane-${i}`; this.createPane(id, 'XAUUSD', '1d'); } // Destroy excess panes for (let i = targetCount; i < currentIds.length; i++) { this.destroyPane(currentIds[i]); } // Ensure active pane is valid if (!this.panes.has(this.activePaneId)) { this.activePaneId = `pane-0`; } // Update grid CSS if (this._gridEl) { LAYOUT_PRESETS.forEach(lp => this._gridEl.classList.remove(`layout-${lp}`)); this._gridEl.classList.add(`layout-${preset}`); } this.save(); return { created: targetCount - currentIds.length, destroyed: Math.max(0, currentIds.length - targetCount) }; }, /* ── Persistence ───────────────────────────── */ save() { const data = { version: 2, layoutPreset: this.layoutPreset, activePaneId: this.activePaneId, panes: Array.from(this.panes.values()).map(p => p.toJSON()), }; try { localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(data)); } catch (_) {} }, restore() { try { const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!data || ![1, 2].includes(data.version)) return null; return data; } catch (_) { return null; } }, /* ── Pane DOM builder ──────────────────────── */ buildPaneDOM(pane) { const container = document.createElement('div'); container.className = 'chart-pane'; container.dataset.paneId = pane.paneId; if (pane.paneId === this.activePaneId) container.classList.add('active'); container.innerHTML = `
${pane.symbol} · ${pane.interval} --
`; pane.containerEl = container; pane.chartEl = container.querySelector('.pane-chart'); pane.loaderEl = container.querySelector('.pane-loader'); pane.gaugesEl = container.querySelector('.pane-gauges'); pane.paneHeaderEl = container.querySelector('.pane-header-mini'); pane.priceEl = container.querySelector('.pane-price'); // Click to activate container.addEventListener('click', () => { this.setActivePane(pane.paneId); }); return container; }, /* ── Render all panes into grid ────────────── */ renderGrid() { if (!this._gridEl) return; // Clear grid this._gridEl.innerHTML = ''; // Set layout class LAYOUT_PRESETS.forEach(lp => this._gridEl.classList.remove(`layout-${lp}`)); this._gridEl.classList.add(`layout-${this.layoutPreset}`); // Build pane DOMs for (const [, pane] of this.panes) { const el = this.buildPaneDOM(pane); this._gridEl.appendChild(el); } }, /* ── Update pane header info ───────────────── */ updatePaneHeader(paneId, symbol, interval, price) { const pane = this.panes.get(paneId); if (!pane || !pane.paneHeaderEl) return; const symEl = pane.paneHeaderEl.querySelector('.pane-symbol'); const intEl = pane.paneHeaderEl.querySelector('.pane-interval'); if (symEl) symEl.textContent = symbol || pane.symbol; if (intEl) intEl.textContent = interval || pane.interval; if (price !== undefined && pane.priceEl) { pane.priceEl.textContent = price; } }, /* ── Utility ───────────────────────────────── */ getAllPaneIds() { return Array.from(this.panes.keys()); }, getPaneCount() { return this.panes.size; } }; /* ── Expose to global scope ───────────────────── */ window.PaneState = PaneState; window.StreamManager = StreamManager; window.DataCoordinator = DataCoordinator; window.Workspace = Workspace; window.LAYOUT_PRESETS = LAYOUT_PRESETS; window.LAYOUT_GRID_MAP = LAYOUT_GRID_MAP; PaneState.prototype.renderGauges = function renderPaneGaugeOverride() { if (typeof window.renderPaneCompactGauges === 'function') { window.renderPaneCompactGauges(this); return; } if (!this.gaugesEl) { return; } if (!this.lastAnalysis || !this.lastAnalysis.payload || !this.lastAnalysis.payload.analysis) { this.gaugesEl.innerHTML = ''; return; } const analysis = this.lastAnalysis.payload.analysis; const technical = analysis.dashboard?.technical || analysis.technicals || {}; const ai = analysis.dashboard?.ai || analysis.ai_gauge || {}; const summary = analysis.dashboard?.summary || analysis.summary || {}; const verdict = this.lastAnalysis.payload.verdict || summary.signal || '--'; const trendScore = Number(technical.score ?? technical.trend_score ?? 50); const strengthScore = Number(summary.confidence ?? summary.strength_score ?? ai.score ?? 50); const aiScore = Number(ai.score ?? ai.confidence ?? summary.ai_score ?? 50); const tone = (score) => score >= 60 ? 'bull' : score <= 40 ? 'bear' : 'flat'; this.gaugesEl.innerHTML = `
T${Math.round(trendScore)}
S${Math.round(strengthScore)}
AI${Math.round(aiScore)}
${String(verdict).replace(/_/g, ' ')}
`; }; Workspace.setActivePane = function setActivePaneOverride(id) { if (!this.panes.has(id)) return; const prevId = this.activePaneId; this.activePaneId = id; if (this._gridEl) { this._gridEl.querySelectorAll('.chart-pane').forEach((el) => { el.classList.toggle('active', el.dataset.paneId === id); }); } if (prevId !== id && this._onActivePaneChange) { this._onActivePaneChange(id); } this.panes.forEach((pane) => { if (typeof pane.renderGauges === 'function') { pane.renderGauges(); } if (typeof window.renderPaneAnalysisUI === 'function') { window.renderPaneAnalysisUI(pane); } }); if (prevId !== id && typeof this.save === 'function') { this.save(); } };