| import apiClient from './apiClient.js'; |
| import errorHelper from './errorHelper.js'; |
| import { createAdvancedLineChart, createCandlestickChart, createVolumeChart } from './tradingview-charts.js'; |
|
|
| |
| 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; |
| |
| |
| this.renderOptions(); |
| |
| |
| this.symbolInput.value = 'BTC - Bitcoin'; |
| |
| |
| this.symbolInput.addEventListener('input', (e) => { |
| const query = e.target.value.trim().toUpperCase(); |
| this.filterSymbols(query); |
| }); |
| |
| |
| this.symbolInput.addEventListener('focus', () => { |
| this.symbolDropdown.style.display = 'block'; |
| this.filterSymbols(this.symbolInput.value.trim().toUpperCase()); |
| }); |
| |
| |
| 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(''); |
| |
| |
| 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() { |
| |
| 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(); |
| }); |
| }); |
| |
| |
| if (this.loadButton) { |
| this.loadButton.addEventListener('click', async (e) => { |
| e.preventDefault(); |
| |
| 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(); |
| }); |
| } |
| |
| |
| if (this.indicatorButtons.length > 0) { |
| this.indicatorButtons.forEach((btn) => { |
| btn.addEventListener('click', () => { |
| btn.classList.toggle('active'); |
| |
| }); |
| }); |
| } |
| |
| |
| 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; |
| |
| |
| 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> |
| `; |
| } |
| |
| |
| if (this.chartTitle) { |
| this.chartTitle.textContent = `${symbol} Price Chart (${this.timeframe})`; |
| } |
| |
| try { |
| const result = await apiClient.getPriceChart(symbol, this.timeframe); |
| |
| |
| 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(); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| }); |
| |
| |
| if (this.chart) { |
| this.chart.destroy(); |
| } |
| |
| |
| 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; |
| |
| |
| 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; |
|
|
| |
| 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) |
| })); |
|
|
| |
| this.chart = createAdvancedLineChart('chart-lab-canvas', priceData, { |
| showMA20, |
| showMA50, |
| showRSI, |
| showVolume |
| }); |
|
|
| |
| if (showVolume && priceData.some(p => p.volume > 0)) { |
| const volumeContainer = this.section.querySelector('[data-volume-chart]'); |
| if (volumeContainer) { |
| createVolumeChart('volume-chart-canvas', priceData); |
| } |
| } |
| |
| |
| 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; |
|
|