/**
* 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;
this.updateTimers = {}; // Track auto-update timers
this.autoUpdateEnabled = false; // Auto-update state
console.log('ChartManager initialized with timeframes:', timeframes);
}
/**
* Start auto-updating charts
*/
startAutoUpdate() {
if (this.autoUpdateEnabled) {
console.log('Auto-update already enabled');
return;
}
this.autoUpdateEnabled = true;
console.log('Starting chart auto-update...');
// Update 1s chart every 20 seconds
if (this.timeframes.includes('1s')) {
this.updateTimers['1s'] = setInterval(() => {
this.updateChart('1s');
}, 20000); // 20 seconds
}
// Update 1m chart - sync to whole minutes + every 20s
if (this.timeframes.includes('1m')) {
// Calculate ms until next whole minute
const now = new Date();
const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
// Update on next whole minute
setTimeout(() => {
this.updateChart('1m');
// Then update every 20s
this.updateTimers['1m'] = setInterval(() => {
this.updateChart('1m');
}, 20000); // 20 seconds
}, msUntilNextMinute);
}
console.log('Auto-update enabled for:', Object.keys(this.updateTimers));
}
/**
* Stop auto-updating charts
*/
stopAutoUpdate() {
if (!this.autoUpdateEnabled) {
return;
}
this.autoUpdateEnabled = false;
// Clear all timers
Object.values(this.updateTimers).forEach(timer => clearInterval(timer));
this.updateTimers = {};
console.log('Auto-update stopped');
}
/**
* Update a single chart with fresh data
*/
async updateChart(timeframe) {
try {
const response = await fetch(`/api/chart-data?timeframe=${timeframe}&limit=1000`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.success && data.data && data.data[timeframe]) {
const chartData = data.data[timeframe];
const plotId = `plot-${timeframe}`;
// Update chart using Plotly.react (efficient update)
const candlestickUpdate = {
x: [chartData.timestamps],
open: [chartData.open],
high: [chartData.high],
low: [chartData.low],
close: [chartData.close]
};
const volumeUpdate = {
x: [chartData.timestamps],
y: [chartData.volume]
};
Plotly.restyle(plotId, candlestickUpdate, [0]);
Plotly.restyle(plotId, volumeUpdate, [1]);
console.log(`Updated ${timeframe} chart at ${new Date().toLocaleTimeString()}`);
}
} catch (error) {
console.error(`Error updating ${timeframe} chart:`, error);
}
}
/**
* 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
const shapes = [];
const annotations = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
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]) => {
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle (above the high)
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot of this level
if (pivot.is_last) {
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
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle (below the low)
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
// Draw horizontal line ONLY for last pivot of this level
if (pivot.is_last) {
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
});
}
});
}
});
// Add pivot dots trace if we have any
if (pivotDots.x.length > 0) {
chartData.push(pivotDots);
}
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 and annotations
plotElement.on('plotly_click', (eventData) => {
// Check if this is an annotation click by looking at the clicked element
if (eventData.points && eventData.points.length > 0) {
const point = eventData.points[0];
// If it's a shape click (annotation line), try to find the annotation
if (point.data && point.data.name && point.data.name.startsWith('line_')) {
const annotationId = point.data.name.replace('line_', '');
console.log('Line annotation clicked:', annotationId);
this.handleAnnotationClick(annotationId, 'edit');
return; // Don't process as regular chart click
}
}
// Regular chart click for annotation marking
this.handleChartClick(timeframe, eventData);
});
// Add click handler for annotations
plotElement.on('plotly_clickannotation', (eventData) => {
console.log('=== plotly_clickannotation event fired ===');
console.log('Event data:', eventData);
console.log('Annotation:', eventData.annotation);
const annotationName = eventData.annotation.name;
console.log('Annotation name:', annotationName);
if (annotationName) {
const parts = annotationName.split('_');
const action = parts[0]; // 'entry', 'exit', or 'delete'
const annotationId = parts[1];
console.log(`Parsed - Action: ${action}, ID: ${annotationId}`);
if (action === 'delete') {
this.handleAnnotationClick(annotationId, 'delete');
} else {
this.handleAnnotationClick(annotationId, 'edit');
}
} else {
console.log('No annotation name found in click event');
}
});
// Add hover handler to update info
plotElement.on('plotly_hover', (eventData) => {
this.updateChartInfo(timeframe, eventData);
});
// Add relayout handler for infinite scroll (load more data when zooming/panning)
plotElement.on('plotly_relayout', (eventData) => {
this.handleChartRelayout(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 = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
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]) => {
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
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
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
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
});
}
});
}
});
// Add pivot dots trace if we have any
if (pivotDots.x.length > 0) {
chartData.push(pivotDots);
}
}
// 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}`
});
});
// Get existing pivot annotations (they have specific names like L1H, L2L)
const existingLayout = chart.element.layout || {};
const existingAnnotations = existingLayout.annotations || [];
const pivotAnnotations = existingAnnotations.filter(ann =>
ann.text && ann.text.match(/^L\d+[HL]$/)
);
// Merge pivot annotations with trade annotations
const allAnnotations = [...pivotAnnotations, ...plotlyAnnotations];
// Get existing pivot shapes
const existingShapes = existingLayout.shapes || [];
const pivotShapes = existingShapes.filter(shape =>
shape.layer === 'below' && shape.line && shape.line.dash === 'dash'
);
// Merge pivot shapes with trade annotation shapes
const allShapes = [...pivotShapes, ...plotlyShapes];
// Update chart layout with merged annotations and shapes
Plotly.relayout(chart.plotId, {
annotations: allAnnotations,
shapes: allShapes
});
console.log(`Updated ${timeframeAnnotations.length} trade annotations for ${timeframe} (preserved ${pivotAnnotations.length} pivot annotations)`);
}
/**
* Handle annotation click for editing/deleting
*/
handleAnnotationClick(annotationId, action) {
console.log(`=== handleAnnotationClick called ===`);
console.log(` Action: ${action}`);
console.log(` Annotation ID: ${annotationId}`);
console.log(` window.deleteAnnotation type: ${typeof window.deleteAnnotation}`);
if (action === 'delete') {
console.log('Delete action confirmed, showing confirm dialog...');
if (confirm('Delete this annotation?')) {
console.log('User confirmed deletion');
if (window.deleteAnnotation) {
console.log('Calling window.deleteAnnotation...');
window.deleteAnnotation(annotationId);
} else {
console.error('window.deleteAnnotation is not available!');
alert('Delete function not available. Please refresh the page.');
}
} else {
console.log('User cancelled deletion');
}
} else if (action === 'edit') {
console.log('Edit action');
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;
// Create a better edit dialog using Bootstrap modal
this.showEditDialog(annotationId, annotation);
}
/**
* Show edit dialog for annotation
*/
showEditDialog(annotationId, annotation) {
// Create modal HTML
const modalHtml = `