|
|
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; |
|
|
|