/**
* ═══════════════════════════════════════════════════════
* 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.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);
}
});
};