/** * ═══════════════════════════════════════════════════════ * KRONOS MULTI-CHART WORKSPACE ENGINE (V1) * Provides: PaneState, ChartPaneController, StreamManager, * DataCoordinator, WorkspaceController * ═══════════════════════════════════════════════════════ */ /* ── Constants ────────────────────────────────────── */ const WORKSPACE_STORAGE_KEY = 'kronos_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 LAYOUT_GRID_MAP = { 1: { cols: 1, rows: 1 }, 2: { cols: 2, rows: 1 }, 4: { cols: 2, rows: 2 }, 8: { cols: 4, rows: 2 }, }; /* ══════════════════════════════════════════════════════ 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; // Chart instances (set by ChartPaneController) this.chartInstance = null; this.candleSeries = null; this.forecastSeries = { candles: null, p50: null, p10: null, p90: null, segments: [] }; this.indicatorSeries = { bbUpper: null, bbMid: null, bbLower: null, rsi: 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 }; this.chartContext = { symbol: null, interval: null }; this.forecastContext = { symbol: null, interval: null, ready: false }; this.priceFormat = { precision: 2, minMove: 0.01 }; // 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.symbol === this.symbol && this.lastAnalysis.interval === this.interval ); } hasMatchingForecast() { return Boolean( this.forecastContext && this.forecastContext.ready && this.forecastContext.symbol === this.symbol && this.forecastContext.interval === this.interval ); } async fetchAI(options = {}) { const horizon = this.horizon || 24; const requestSymbol = this.symbol; const requestInterval = this.interval; const requestHorizon = horizon; const requestKey = `${requestSymbol}|${requestInterval}|${requestHorizon}`; 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 (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; // Show loading in mini gauges const shouldRenderPaneGauges = !(window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0'); if (this.gaugesEl && shouldRenderPaneGauges && !this.hasMatchingAnalysis()) { this.gaugesEl.innerHTML = '
'; } const requestPromise = (async () => { try { const fData = await DataCoordinator.fetchForecast(requestSymbol, requestInterval, requestHorizon, controller.signal); if (controller.signal.aborted) return null; if ( this.symbol !== requestSymbol || this.interval !== requestInterval || (this.horizon || 24) !== requestHorizon ) { return null; } const hasAnalysis = Boolean(fData?.analysis); if (hasAnalysis) { this.lastAnalysis = { payload: fData, symbol: requestSymbol, interval: requestInterval }; } const forecastPoints = Array.isArray(fData.forecast) ? fData.forecast : []; if (this.lastCandleData && this.forecastSeries?.p50) { const fallbackActualPoint = { time: this.lastCandleData.time, value: ( Number(this.lastCandleData.open ?? 0) + Number(this.lastCandleData.high ?? 0) + Number(this.lastCandleData.low ?? 0) + Number(this.lastCandleData.close ?? 0) ) / 4, }; const forecastLine = typeof window.buildForecastLineFromRows === 'function' ? window.buildForecastLineFromRows(forecastPoints, fallbackActualPoint) : [fallbackActualPoint, ...forecastPoints .filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== this.lastCandleData.time) .map(d => ({ time: d.time, value: d.p50 }))]; const hasForecast = forecastLine.length > 1; if (hasForecast) { if (typeof window.renderPaneForecastVisuals === 'function') { window.renderPaneForecastVisuals(this, forecastLine); } else { if (this.forecastSeries.candles?.setData) { this.forecastSeries.candles.setData([]); } if (this.forecastSeries.p10?.setData) { this.forecastSeries.p10.setData([]); } if (this.forecastSeries.p90?.setData) { this.forecastSeries.p90.setData([]); } this.forecastSeries.p50.setData(forecastLine); } this.forecastContext = { symbol: requestSymbol, interval: requestInterval, ready: true }; } } if (typeof window.renderPaneAnalysisUI === 'function') { window.renderPaneAnalysisUI(this); } if ( hasAnalysis && typeof window.Workspace !== 'undefined' && window.Workspace?.activePaneId === this.paneId && typeof window.renderCompactGauges === 'function' ) { window.renderCompactGauges(this.symbol, this.interval, fData); } if ( hasAnalysis && this.analysisOpen && typeof window.renderAnalysisPanel === 'function' && typeof document !== 'undefined' ) { const analysisPanel = document.getElementById('analysisPanel'); if (analysisPanel?.classList.contains('active')) { window.renderAnalysisPanel(this.symbol, this.interval, fData); if (typeof window.updateDashboardScale === 'function') { setTimeout(window.updateDashboardScale, 10); } } } return fData; } catch (e) { if (e.name === 'AbortError') return null; console.error(`[Pane ${this.paneId}] AI fetch error:`, e); if (typeof window.renderPaneAnalysisUI === 'function') { window.renderPaneAnalysisUI(this); } // Retry 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, }; } } /* ══════════════════════════════════════════════════════ 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.__KRONOS_API_BASE || ''; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${apiBase.replace(/^https?:\/\//, wsProtocol)}/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.__KRONOS_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, signal) { return this.fetch( `/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}`, 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: 1, 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 || data.version !== 1) 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); } }); };