Spaces:
Sleeping
Sleeping
| import apiClient from './apiClient.js'; | |
| import errorHelper from './errorHelper.js'; | |
| import { createAdvancedLineChart, createCandlestickChart, createVolumeChart } from './tradingview-charts.js'; | |
| // Cryptocurrency symbols list | |
| const CRYPTO_SYMBOLS = [ | |
| { symbol: 'BTC', name: 'Bitcoin' }, | |
| { symbol: 'ETH', name: 'Ethereum' }, | |
| { symbol: 'BNB', name: 'Binance Coin' }, | |
| { symbol: 'SOL', name: 'Solana' }, | |
| { symbol: 'XRP', name: 'Ripple' }, | |
| { symbol: 'ADA', name: 'Cardano' }, | |
| { symbol: 'DOGE', name: 'Dogecoin' }, | |
| { symbol: 'DOT', name: 'Polkadot' }, | |
| { symbol: 'MATIC', name: 'Polygon' }, | |
| { symbol: 'AVAX', name: 'Avalanche' }, | |
| { symbol: 'LINK', name: 'Chainlink' }, | |
| { symbol: 'UNI', name: 'Uniswap' }, | |
| { symbol: 'LTC', name: 'Litecoin' }, | |
| { symbol: 'ATOM', name: 'Cosmos' }, | |
| { symbol: 'ALGO', name: 'Algorand' }, | |
| { symbol: 'TRX', name: 'Tron' }, | |
| { symbol: 'XLM', name: 'Stellar' }, | |
| { symbol: 'VET', name: 'VeChain' }, | |
| { symbol: 'FIL', name: 'Filecoin' }, | |
| { symbol: 'ETC', name: 'Ethereum Classic' }, | |
| { symbol: 'AAVE', name: 'Aave' }, | |
| { symbol: 'MKR', name: 'Maker' }, | |
| { symbol: 'COMP', name: 'Compound' }, | |
| { symbol: 'SUSHI', name: 'SushiSwap' }, | |
| { symbol: 'YFI', name: 'Yearn Finance' }, | |
| ]; | |
| class ChartLabView { | |
| constructor(section) { | |
| this.section = section; | |
| this.symbolInput = section.querySelector('[data-chart-symbol-input]'); | |
| this.symbolDropdown = section.querySelector('[data-chart-symbol-dropdown]'); | |
| this.symbolOptions = section.querySelector('[data-chart-symbol-options]'); | |
| this.timeframeButtons = section.querySelectorAll('[data-timeframe]'); | |
| this.indicatorButtons = section.querySelectorAll('[data-indicator]'); | |
| this.loadButton = section.querySelector('[data-load-chart]'); | |
| this.runAnalysisButton = section.querySelector('[data-run-analysis]'); | |
| this.canvas = section.querySelector('#price-chart'); | |
| this.analysisOutput = section.querySelector('[data-analysis-output]'); | |
| this.chartTitle = section.querySelector('[data-chart-title]'); | |
| this.chartLegend = section.querySelector('[data-chart-legend]'); | |
| this.chart = null; | |
| this.symbol = 'BTC'; | |
| this.timeframe = '7d'; | |
| this.filteredSymbols = [...CRYPTO_SYMBOLS]; | |
| } | |
| async init() { | |
| this.setupCombobox(); | |
| this.bindEvents(); | |
| await this.loadChart(); | |
| } | |
| setupCombobox() { | |
| if (!this.symbolInput || !this.symbolOptions) return; | |
| // Populate options | |
| this.renderOptions(); | |
| // Set initial value | |
| this.symbolInput.value = 'BTC - Bitcoin'; | |
| // Input event for filtering | |
| this.symbolInput.addEventListener('input', (e) => { | |
| const query = e.target.value.trim().toUpperCase(); | |
| this.filterSymbols(query); | |
| }); | |
| // Focus event to show dropdown | |
| this.symbolInput.addEventListener('focus', () => { | |
| this.symbolDropdown.style.display = 'block'; | |
| this.filterSymbols(this.symbolInput.value.trim().toUpperCase()); | |
| }); | |
| // Click outside to close | |
| document.addEventListener('click', (e) => { | |
| if (!this.symbolInput.contains(e.target) && !this.symbolDropdown.contains(e.target)) { | |
| this.symbolDropdown.style.display = 'none'; | |
| } | |
| }); | |
| } | |
| filterSymbols(query) { | |
| if (!query) { | |
| this.filteredSymbols = [...CRYPTO_SYMBOLS]; | |
| } else { | |
| this.filteredSymbols = CRYPTO_SYMBOLS.filter(item => | |
| item.symbol.includes(query) || | |
| item.name.toUpperCase().includes(query) | |
| ); | |
| } | |
| this.renderOptions(); | |
| } | |
| renderOptions() { | |
| if (!this.symbolOptions) return; | |
| if (this.filteredSymbols.length === 0) { | |
| this.symbolOptions.innerHTML = '<div class="combobox-option disabled">No results found</div>'; | |
| return; | |
| } | |
| this.symbolOptions.innerHTML = this.filteredSymbols.map(item => ` | |
| <div class="combobox-option" data-symbol="${item.symbol}"> | |
| <strong>${item.symbol}</strong> | |
| <span>${item.name}</span> | |
| </div> | |
| `).join(''); | |
| // Add click handlers | |
| this.symbolOptions.querySelectorAll('.combobox-option').forEach(option => { | |
| if (!option.classList.contains('disabled')) { | |
| option.addEventListener('click', () => { | |
| const symbol = option.dataset.symbol; | |
| const item = CRYPTO_SYMBOLS.find(i => i.symbol === symbol); | |
| if (item) { | |
| this.symbol = symbol; | |
| this.symbolInput.value = `${item.symbol} - ${item.name}`; | |
| this.symbolDropdown.style.display = 'none'; | |
| this.loadChart(); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| bindEvents() { | |
| // Timeframe buttons | |
| this.timeframeButtons.forEach((btn) => { | |
| btn.addEventListener('click', async () => { | |
| this.timeframeButtons.forEach((b) => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| this.timeframe = btn.dataset.timeframe; | |
| await this.loadChart(); | |
| }); | |
| }); | |
| // Load chart button | |
| if (this.loadButton) { | |
| this.loadButton.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| // Extract symbol from input | |
| const inputValue = this.symbolInput.value.trim(); | |
| if (inputValue) { | |
| const match = inputValue.match(/^([A-Z0-9]+)/); | |
| if (match) { | |
| this.symbol = match[1].toUpperCase(); | |
| } else { | |
| this.symbol = inputValue.toUpperCase(); | |
| } | |
| } | |
| await this.loadChart(); | |
| }); | |
| } | |
| // Indicator buttons | |
| if (this.indicatorButtons.length > 0) { | |
| this.indicatorButtons.forEach((btn) => { | |
| btn.addEventListener('click', () => { | |
| btn.classList.toggle('active'); | |
| // Don't auto-run, wait for Run Analysis button | |
| }); | |
| }); | |
| } | |
| // Run analysis button | |
| if (this.runAnalysisButton) { | |
| this.runAnalysisButton.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| await this.runAnalysis(); | |
| }); | |
| } | |
| } | |
| async loadChart() { | |
| if (!this.canvas) return; | |
| const symbol = this.symbol.trim().toUpperCase() || 'BTC'; | |
| if (!symbol) { | |
| this.symbol = 'BTC'; | |
| if (this.symbolInput) this.symbolInput.value = 'BTC - Bitcoin'; | |
| } | |
| const container = this.canvas.closest('.chart-wrapper') || this.canvas.parentElement; | |
| // Show loading state | |
| if (container) { | |
| let loadingNode = container.querySelector('.chart-loading'); | |
| if (!loadingNode) { | |
| loadingNode = document.createElement('div'); | |
| loadingNode.className = 'chart-loading'; | |
| container.insertBefore(loadingNode, this.canvas); | |
| } | |
| loadingNode.innerHTML = ` | |
| <div class="loading-spinner"></div> | |
| <p>Loading ${symbol} chart data...</p> | |
| `; | |
| } | |
| // Update title | |
| if (this.chartTitle) { | |
| this.chartTitle.textContent = `${symbol} Price Chart (${this.timeframe})`; | |
| } | |
| try { | |
| const result = await apiClient.getPriceChart(symbol, this.timeframe); | |
| // Remove loading | |
| if (container) { | |
| const loadingNode = container.querySelector('.chart-loading'); | |
| if (loadingNode) loadingNode.remove(); | |
| } | |
| if (!result.ok) { | |
| const errorAnalysis = errorHelper.analyzeError(new Error(result.error), { symbol, timeframe: this.timeframe }); | |
| if (container) { | |
| let errorNode = container.querySelector('.chart-error'); | |
| if (!errorNode) { | |
| errorNode = document.createElement('div'); | |
| errorNode.className = 'inline-message inline-error chart-error'; | |
| container.appendChild(errorNode); | |
| } | |
| errorNode.innerHTML = ` | |
| <strong>Error loading chart:</strong> | |
| <p>${result.error || 'Failed to load chart data'}</p> | |
| <p><small>Symbol: ${symbol} | Timeframe: ${this.timeframe}</small></p> | |
| `; | |
| } | |
| return; | |
| } | |
| if (container) { | |
| const errorNode = container.querySelector('.chart-error'); | |
| if (errorNode) errorNode.remove(); | |
| } | |
| // Parse chart data | |
| const chartData = result.data || {}; | |
| const points = chartData.data || chartData || []; | |
| if (!points || points.length === 0) { | |
| if (container) { | |
| const errorNode = document.createElement('div'); | |
| errorNode.className = 'inline-message inline-warn'; | |
| errorNode.innerHTML = '<strong>No data available</strong><p>No price data found for this symbol and timeframe.</p>'; | |
| container.appendChild(errorNode); | |
| } | |
| return; | |
| } | |
| // Format labels and data | |
| const labels = points.map((point) => { | |
| const ts = point.time || point.timestamp || point.date; | |
| if (!ts) return ''; | |
| const date = new Date(ts); | |
| if (this.timeframe === '1d') { | |
| return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| }); | |
| const prices = points.map((point) => { | |
| const price = point.price || point.close || point.value || 0; | |
| return parseFloat(price) || 0; | |
| }); | |
| // Destroy existing chart | |
| if (this.chart) { | |
| this.chart.destroy(); | |
| } | |
| // Calculate min/max for better scaling | |
| const minPrice = Math.min(...prices); | |
| const maxPrice = Math.max(...prices); | |
| const priceRange = maxPrice - minPrice; | |
| const firstPrice = prices[0]; | |
| const lastPrice = prices[prices.length - 1]; | |
| const priceChange = lastPrice - firstPrice; | |
| const priceChangePercent = ((priceChange / firstPrice) * 100).toFixed(2); | |
| const isPriceUp = priceChange >= 0; | |
| // Get indicator states | |
| const showMA20 = this.section.querySelector('[data-indicator="MA20"]')?.checked || false; | |
| const showMA50 = this.section.querySelector('[data-indicator="MA50"]')?.checked || false; | |
| const showRSI = this.section.querySelector('[data-indicator="RSI"]')?.checked || false; | |
| const showVolume = this.section.querySelector('[data-indicator="Volume"]')?.checked || false; | |
| // Prepare price data for TradingView chart | |
| const priceData = points.map((point, index) => ({ | |
| time: point.time || point.timestamp || point.date || new Date().getTime() + (index * 60000), | |
| price: parseFloat(point.price || point.close || point.value || 0), | |
| volume: parseFloat(point.volume || 0) | |
| })); | |
| // Create TradingView-style chart with indicators | |
| this.chart = createAdvancedLineChart('chart-lab-canvas', priceData, { | |
| showMA20, | |
| showMA50, | |
| showRSI, | |
| showVolume | |
| }); | |
| // If volume is enabled, create separate volume chart | |
| if (showVolume && priceData.some(p => p.volume > 0)) { | |
| const volumeContainer = this.section.querySelector('[data-volume-chart]'); | |
| if (volumeContainer) { | |
| createVolumeChart('volume-chart-canvas', priceData); | |
| } | |
| } | |
| // Update legend with TradingView-style info | |
| if (this.chartLegend && prices.length > 0) { | |
| const currentPrice = prices[prices.length - 1]; | |
| const firstPrice = prices[0]; | |
| const change = currentPrice - firstPrice; | |
| const changePercent = ((change / firstPrice) * 100).toFixed(2); | |
| const isUp = change >= 0; | |
| this.chartLegend.innerHTML = ` | |
| <div class="legend-item"> | |
| <span class="legend-label">Price</span> | |
| <span class="legend-value">$${currentPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span> | |
| </div> | |
| <div class="legend-item"> | |
| <span class="legend-label">24h</span> | |
| <span class="legend-value ${isUp ? 'positive' : 'negative'}"> | |
| <span class="legend-arrow">${isUp ? '↑' : '↓'}</span> | |
| ${isUp ? '+' : ''}${changePercent}% | |
| </span> | |
| </div> | |
| <div class="legend-item"> | |
| <span class="legend-label">High</span> | |
| <span class="legend-value">$${maxPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span> | |
| </div> | |
| <div class="legend-item"> | |
| <span class="legend-label">Low</span> | |
| <span class="legend-value">$${minPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span> | |
| </div> | |
| `; | |
| } | |
| } catch (error) { | |
| console.error('Chart loading error:', error); | |
| if (container) { | |
| const errorNode = document.createElement('div'); | |
| errorNode.className = 'inline-message inline-error'; | |
| errorNode.innerHTML = `<strong>Error:</strong><p>${error.message || 'Failed to load chart'}</p>`; | |
| container.appendChild(errorNode); | |
| } | |
| } | |
| } | |
| async runAnalysis() { | |
| if (!this.analysisOutput) return; | |
| const enabledIndicators = Array.from(this.indicatorButtons) | |
| .filter((btn) => btn.classList.contains('active')) | |
| .map((btn) => btn.dataset.indicator); | |
| this.analysisOutput.innerHTML = ` | |
| <div class="analysis-loading"> | |
| <div class="loading-spinner"></div> | |
| <p>Running AI analysis with ${enabledIndicators.length > 0 ? enabledIndicators.join(', ') : 'default'} indicators...</p> | |
| </div> | |
| `; | |
| try { | |
| const result = await apiClient.analyzeChart(this.symbol, this.timeframe, enabledIndicators); | |
| if (!result.ok) { | |
| this.analysisOutput.innerHTML = ` | |
| <div class="inline-message inline-error"> | |
| <strong>Analysis Error:</strong> | |
| <p>${result.error || 'Failed to run analysis'}</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const data = result.data || {}; | |
| const analysis = data.analysis || data; | |
| if (!analysis) { | |
| this.analysisOutput.innerHTML = '<div class="inline-message inline-warn">No AI insights returned.</div>'; | |
| return; | |
| } | |
| const summary = analysis.summary || analysis.narrative?.summary || 'No summary available.'; | |
| const signals = analysis.signals || {}; | |
| const direction = analysis.change_direction || 'N/A'; | |
| const changePercent = analysis.change_percent ?? '—'; | |
| const high = analysis.high ?? '—'; | |
| const low = analysis.low ?? '—'; | |
| const bullets = Object.entries(signals) | |
| .map(([key, value]) => { | |
| const label = value?.label || value || 'n/a'; | |
| const score = value?.score ?? value?.value ?? '—'; | |
| return `<li><strong>${key.toUpperCase()}:</strong> ${label} ${score !== '—' ? `(${score})` : ''}</li>`; | |
| }) | |
| .join(''); | |
| this.analysisOutput.innerHTML = ` | |
| <div class="analysis-results"> | |
| <div class="analysis-header"> | |
| <h5>Analysis Results</h5> | |
| <span class="analysis-badge ${direction.toLowerCase()}">${direction}</span> | |
| </div> | |
| <div class="analysis-metrics"> | |
| <div class="metric-item"> | |
| <span class="metric-label">Direction</span> | |
| <span class="metric-value ${direction.toLowerCase()}">${direction}</span> | |
| </div> | |
| <div class="metric-item"> | |
| <span class="metric-label">Change</span> | |
| <span class="metric-value ${changePercent >= 0 ? 'positive' : 'negative'}"> | |
| ${changePercent >= 0 ? '+' : ''}${changePercent}% | |
| </span> | |
| </div> | |
| <div class="metric-item"> | |
| <span class="metric-label">High</span> | |
| <span class="metric-value">$${high}</span> | |
| </div> | |
| <div class="metric-item"> | |
| <span class="metric-label">Low</span> | |
| <span class="metric-value">$${low}</span> | |
| </div> | |
| </div> | |
| <div class="analysis-summary"> | |
| <h6>Summary</h6> | |
| <p>${summary}</p> | |
| </div> | |
| ${bullets ? ` | |
| <div class="analysis-signals"> | |
| <h6>Signals</h6> | |
| <ul>${bullets}</ul> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| } catch (error) { | |
| console.error('Analysis error:', error); | |
| this.analysisOutput.innerHTML = ` | |
| <div class="inline-message inline-error"> | |
| <strong>Error:</strong> | |
| <p>${error.message || 'Failed to run analysis'}</p> | |
| </div> | |
| `; | |
| } | |
| } | |
| } | |
| export default ChartLabView; | |