/** * ChartManager - Manages Plotly charts for multi-timeframe visualization */ class ChartManager { constructor(containerId, timeframes) { this.containerId = containerId; this.timeframes = timeframes; this.charts = {}; this.annotations = {}; this.syncedTime = null; console.log('ChartManager initialized with timeframes:', timeframes); } /** * Initialize charts for all timeframes with pivot bounds */ initializeCharts(chartData, pivotBounds = null) { console.log('Initializing charts with data:', chartData); console.log('Pivot bounds:', pivotBounds); // Use requestAnimationFrame to batch chart creation let index = 0; const createNextChart = () => { if (index < this.timeframes.length) { const timeframe = this.timeframes[index]; if (chartData[timeframe]) { this.createChart(timeframe, chartData[timeframe], pivotBounds); } index++; requestAnimationFrame(createNextChart); } else { // Enable crosshair after all charts are created this.enableCrosshair(); } }; requestAnimationFrame(createNextChart); } /** * Create a single chart for a timeframe */ createChart(timeframe, data, pivotBounds = null) { const plotId = `plot-${timeframe}`; const plotElement = document.getElementById(plotId); if (!plotElement) { console.error(`Plot element not found: ${plotId}`); return; } // Create candlestick trace const candlestickTrace = { x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close, type: 'candlestick', name: 'Price', increasing: { line: { color: '#10b981', width: 1 }, fillcolor: '#10b981' }, decreasing: { line: { color: '#ef4444', width: 1 }, fillcolor: '#ef4444' }, xaxis: 'x', yaxis: 'y' }; // Create volume trace with color based on price direction const volumeColors = data.close.map((close, i) => { if (i === 0) return '#3b82f6'; return close >= data.open[i] ? '#10b981' : '#ef4444'; }); const volumeTrace = { x: data.timestamps, y: data.volume, type: 'bar', name: 'Volume', yaxis: 'y2', marker: { color: volumeColors, opacity: 0.3 }, hoverinfo: 'y' }; const layout = { title: '', showlegend: false, xaxis: { rangeslider: { visible: false }, gridcolor: '#374151', color: '#9ca3af', showgrid: true, zeroline: false }, yaxis: { title: { text: 'Price (USD)', font: { size: 10 } }, gridcolor: '#374151', color: '#9ca3af', showgrid: true, zeroline: false, domain: [0.3, 1] }, yaxis2: { title: { text: 'Volume', font: { size: 10 } }, gridcolor: '#374151', color: '#9ca3af', showgrid: false, zeroline: false, domain: [0, 0.25] }, plot_bgcolor: '#1f2937', paper_bgcolor: '#1f2937', font: { color: '#f8f9fa', size: 11 }, margin: { l: 60, r: 20, t: 10, b: 40 }, hovermode: 'x unified', dragmode: 'pan', // Performance optimizations autosize: true, staticPlot: false }; const config = { responsive: true, displayModeBar: true, modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'], displaylogo: false, scrollZoom: true, // Performance optimizations doubleClick: false, showAxisDragHandles: false, showAxisRangeEntryBoxes: false }; // Prepare chart data with pivot bounds const chartData = [candlestickTrace, volumeTrace]; // Add pivot markers from chart data (last high/low for each level L1-L5) const shapes = []; const annotations = []; if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) { const xMin = data.timestamps[0]; const xMax = data.timestamps[data.timestamps.length - 1]; // Process each timestamp that has pivot markers Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => { // Draw horizontal lines for last high pivots (resistance) if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); // Add label for the level annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}H`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); }); } // Draw horizontal lines for last low pivots (support) if (pivots.lows && pivots.lows.length > 0) { pivots.lows.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'low'); shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); // Add label for the level annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}L`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); }); } }); console.log(`Added ${shapes.length} pivot levels to ${timeframe} chart`); } // Add shapes and annotations to layout if (shapes.length > 0) { layout.shapes = shapes; } if (annotations.length > 0) { layout.annotations = annotations; } // Use Plotly.react for better performance on updates Plotly.newPlot(plotId, chartData, layout, config).then(() => { // Optimize rendering after initial plot plotElement._fullLayout._replotting = false; }); // Store chart reference this.charts[timeframe] = { plotId: plotId, data: data, element: plotElement, annotations: [] }; // Add click handler for chart plotElement.on('plotly_click', (eventData) => { this.handleChartClick(timeframe, eventData); }); // Add click handler for annotations plotElement.on('plotly_clickannotation', (eventData) => { const annotationName = eventData.annotation.name; if (annotationName) { const parts = annotationName.split('_'); const action = parts[0]; // 'entry', 'exit', or 'delete' const annotationId = parts[1]; if (action === 'delete') { this.handleAnnotationClick(annotationId, 'delete'); } else { this.handleAnnotationClick(annotationId, 'edit'); } } }); // Add hover handler to update info plotElement.on('plotly_hover', (eventData) => { this.updateChartInfo(timeframe, eventData); }); console.log(`Chart created for ${timeframe} with ${data.timestamps.length} candles`); } /** * Handle chart click for annotation */ handleChartClick(timeframe, eventData) { if (!eventData.points || eventData.points.length === 0) return; const point = eventData.points[0]; // Get the actual price from candlestick data let price; if (point.data.type === 'candlestick') { // For candlestick, use close price price = point.data.close[point.pointIndex]; } else if (point.data.type === 'bar') { // Skip volume bar clicks return; } else { price = point.y; } const clickData = { timeframe: timeframe, timestamp: point.x, price: price, index: point.pointIndex }; console.log('Chart clicked:', clickData); // Trigger annotation manager if (window.appState && window.appState.annotationManager) { window.appState.annotationManager.handleChartClick(clickData); } } /** * Update charts with new data including pivot levels */ updateCharts(newData, pivotBounds = null) { Object.keys(newData).forEach(timeframe => { if (this.charts[timeframe]) { const plotId = this.charts[timeframe].plotId; const data = newData[timeframe]; // Create volume colors const volumeColors = data.close.map((close, i) => { if (i === 0) return '#3b82f6'; return close >= data.open[i] ? '#10b981' : '#ef4444'; }); // Prepare chart data const chartData = [ { x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close, type: 'candlestick', name: 'Price', increasing: { line: { color: '#10b981', width: 1 }, fillcolor: '#10b981' }, decreasing: { line: { color: '#ef4444', width: 1 }, fillcolor: '#ef4444' } }, { x: data.timestamps, y: data.volume, type: 'bar', yaxis: 'y2', name: 'Volume', marker: { color: volumeColors, opacity: 0.3 }, hoverinfo: 'y' } ]; // Add pivot markers from chart data const shapes = []; const annotations = []; if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) { const xMin = data.timestamps[0]; const xMax = data.timestamps[data.timestamps.length - 1]; // Process each timestamp that has pivot markers Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => { // Draw horizontal lines for last high pivots if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}H`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); }); } // Draw horizontal lines for last low pivots if (pivots.lows && pivots.lows.length > 0) { pivots.lows.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'low'); shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}L`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); }); } }); } // Use Plotly.react for efficient updates const update = { shapes: shapes, annotations: annotations }; Plotly.react(plotId, chartData, update); } }); } /** * Add annotation to charts */ addAnnotation(annotation) { console.log('Adding annotation to charts:', annotation); // Store annotation this.annotations[annotation.annotation_id] = annotation; // Add markers to relevant timeframe chart const timeframe = annotation.timeframe; if (this.charts[timeframe]) { // TODO: Add visual markers using Plotly annotations this.updateChartAnnotations(timeframe); } } /** * Remove annotation from charts */ removeAnnotation(annotationId) { if (this.annotations[annotationId]) { const annotation = this.annotations[annotationId]; delete this.annotations[annotationId]; // Update chart if (this.charts[annotation.timeframe]) { this.updateChartAnnotations(annotation.timeframe); } } } /** * Update chart annotations */ updateChartAnnotations(timeframe) { const chart = this.charts[timeframe]; if (!chart) return; // Get annotations for this timeframe const timeframeAnnotations = Object.values(this.annotations) .filter(ann => ann.timeframe === timeframe); // Build Plotly annotations and shapes const plotlyAnnotations = []; const plotlyShapes = []; timeframeAnnotations.forEach(ann => { const entryTime = ann.entry.timestamp; const exitTime = ann.exit.timestamp; const entryPrice = ann.entry.price; const exitPrice = ann.exit.price; // Entry marker (clickable) plotlyAnnotations.push({ x: entryTime, y: entryPrice, text: '▲', showarrow: false, font: { size: 20, color: ann.direction === 'LONG' ? '#10b981' : '#ef4444' }, xanchor: 'center', yanchor: 'bottom', captureevents: true, name: `entry_${ann.annotation_id}` }); // Exit marker (clickable) plotlyAnnotations.push({ x: exitTime, y: exitPrice, text: '▼', showarrow: false, font: { size: 20, color: ann.direction === 'LONG' ? '#10b981' : '#ef4444' }, xanchor: 'center', yanchor: 'top', captureevents: true, name: `exit_${ann.annotation_id}` }); // P&L label with delete button const midTime = new Date((new Date(entryTime).getTime() + new Date(exitTime).getTime()) / 2); const midPrice = (entryPrice + exitPrice) / 2; const pnlColor = ann.profit_loss_pct >= 0 ? '#10b981' : '#ef4444'; plotlyAnnotations.push({ x: midTime, y: midPrice, text: `${ann.profit_loss_pct >= 0 ? '+' : ''}${ann.profit_loss_pct.toFixed(2)}% 🗑️`, showarrow: true, arrowhead: 0, ax: 0, ay: -40, font: { size: 12, color: pnlColor, family: 'monospace' }, bgcolor: '#1f2937', bordercolor: pnlColor, borderwidth: 1, borderpad: 4, captureevents: true, name: `delete_${ann.annotation_id}` }); // Connecting line (clickable for selection) plotlyShapes.push({ type: 'line', x0: entryTime, y0: entryPrice, x1: exitTime, y1: exitPrice, line: { color: ann.direction === 'LONG' ? '#10b981' : '#ef4444', width: 2, dash: 'dash' }, name: `line_${ann.annotation_id}` }); }); // Update chart layout with annotations Plotly.relayout(chart.plotId, { annotations: plotlyAnnotations, shapes: plotlyShapes }); console.log(`Updated ${timeframeAnnotations.length} annotations for ${timeframe}`); } /** * Handle annotation click for editing/deleting */ handleAnnotationClick(annotationId, action) { console.log(`Annotation ${action}:`, annotationId); if (action === 'delete') { if (confirm('Delete this annotation?')) { if (window.deleteAnnotation) { window.deleteAnnotation(annotationId); } } } else if (action === 'edit') { if (window.appState && window.appState.chartManager) { window.appState.chartManager.editAnnotation(annotationId); } } } /** * Highlight annotation */ highlightAnnotation(annotationId) { const annotation = this.annotations[annotationId]; if (!annotation) return; const timeframe = annotation.timeframe; const chart = this.charts[timeframe]; if (!chart) return; // Flash the annotation by temporarily changing its color const originalAnnotations = chart.element.layout.annotations || []; const highlightedAnnotations = originalAnnotations.map(ann => { // Create a copy with highlighted color return { ...ann, font: { ...ann.font, color: '#fbbf24' // Yellow highlight } }; }); Plotly.relayout(chart.plotId, { annotations: highlightedAnnotations }); // Restore original colors after 1 second setTimeout(() => { this.updateChartAnnotations(timeframe); }, 1000); console.log('Highlighted annotation:', annotationId); } /** * Edit annotation - allows moving entry/exit points */ editAnnotation(annotationId) { const annotation = this.annotations[annotationId]; if (!annotation) return; // Show edit dialog const action = prompt( 'Edit annotation:\n' + '1 - Move entry point\n' + '2 - Move exit point\n' + '3 - Delete annotation\n' + 'Enter choice (1-3):', '1' ); if (action === '1') { // Move entry point window.showSuccess('Click on chart to set new entry point'); // Store annotation for editing if (window.appState && window.appState.annotationManager) { window.appState.annotationManager.editingAnnotation = { annotation_id: annotationId, original: annotation, editMode: 'entry' }; // Remove current annotation from display this.removeAnnotation(annotationId); // Show exit marker as reference const chart = this.charts[annotation.timeframe]; if (chart) { Plotly.relayout(chart.plotId, { annotations: [{ x: annotation.exit.timestamp, y: annotation.exit.price, text: '▼ (exit)', showarrow: true, arrowhead: 2, ax: 0, ay: 40, font: { size: 14, color: '#9ca3af' } }] }); } } } else if (action === '2') { // Move exit point window.showSuccess('Click on chart to set new exit point'); if (window.appState && window.appState.annotationManager) { window.appState.annotationManager.editingAnnotation = { annotation_id: annotationId, original: annotation, editMode: 'exit' }; // Remove current annotation from display this.removeAnnotation(annotationId); // Show entry marker as reference const chart = this.charts[annotation.timeframe]; if (chart) { Plotly.relayout(chart.plotId, { annotations: [{ x: annotation.entry.timestamp, y: annotation.entry.price, text: '▲ (entry)', showarrow: true, arrowhead: 2, ax: 0, ay: -40, font: { size: 14, color: '#9ca3af' } }] }); } } } else if (action === '3') { // Delete this.handleAnnotationClick(annotationId, 'delete'); } } /** * Get color for pivot level */ _getPivotColor(level, type) { // Different colors for different levels const highColors = ['#dc3545', '#ff6b6b', '#ff8787', '#ffa8a8', '#ffc9c9']; const lowColors = ['#28a745', '#51cf66', '#69db7c', '#8ce99a', '#b2f2bb']; const colors = type === 'high' ? highColors : lowColors; return colors[Math.min(level - 1, colors.length - 1)]; } /** * Enable crosshair cursor */ enableCrosshair() { // Crosshair is enabled via hovermode in layout console.log('Crosshair enabled'); } /** * Handle zoom */ handleZoom(zoomFactor) { Object.values(this.charts).forEach(chart => { Plotly.relayout(chart.plotId, { 'xaxis.range[0]': null, 'xaxis.range[1]': null }); }); } /** * Reset zoom */ resetZoom() { Object.values(this.charts).forEach(chart => { Plotly.relayout(chart.plotId, { 'xaxis.autorange': true, 'yaxis.autorange': true }); }); } /** * Synchronize time navigation across charts */ syncTimeNavigation(timestamp) { this.syncedTime = timestamp; // Update all charts to center on this timestamp Object.values(this.charts).forEach(chart => { const data = chart.data; const timestamps = data.timestamps; // Find index closest to target timestamp const targetTime = new Date(timestamp); let closestIndex = 0; let minDiff = Infinity; timestamps.forEach((ts, i) => { const diff = Math.abs(new Date(ts) - targetTime); if (diff < minDiff) { minDiff = diff; closestIndex = i; } }); // Center the view on this index const rangeSize = 100; // Show 100 candles const startIndex = Math.max(0, closestIndex - rangeSize / 2); const endIndex = Math.min(timestamps.length - 1, closestIndex + rangeSize / 2); Plotly.relayout(chart.plotId, { 'xaxis.range': [timestamps[startIndex], timestamps[endIndex]] }); }); console.log('Synced charts to timestamp:', timestamp); } /** * Update chart info display on hover */ updateChartInfo(timeframe, eventData) { if (!eventData.points || eventData.points.length === 0) return; const point = eventData.points[0]; const infoElement = document.getElementById(`info-${timeframe}`); if (infoElement && point.data.type === 'candlestick') { const open = point.data.open[point.pointIndex]; const high = point.data.high[point.pointIndex]; const low = point.data.low[point.pointIndex]; const close = point.data.close[point.pointIndex]; infoElement.textContent = `O: ${open.toFixed(2)} H: ${high.toFixed(2)} L: ${low.toFixed(2)} C: ${close.toFixed(2)}`; } } }