trace-chart
Version:
A lightweight, fully customizable financial charting library for global markets
1,422 lines (1,193 loc) • 256 kB
JavaScript
/*!
* trace-chart v0.0.1
* A lightweight, fully customizable financial charting library for global markets
* (c) 2025 Shobhit Srivastava
* Released under the Apache 2.0 License
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.TraceChart = factory());
})(this, (function () { 'use strict';
/**
* ChartEventManager.js - Centralized Event Management System
*
* @author Shobhit Srivastava
* @license Apache 2.0
*
* Centralizes all mouse event handling for the chart to eliminate conflicts
* between drawing system, interaction detection, and ECharts native events.
*
* ARCHITECTURE:
* - Single event handlers per event type
* - Mode-based event routing
* - Proper cleanup and state management
* - Performance optimization through event consolidation
*/
class ChartEventManager {
constructor(chartEngine) {
this.chartEngine = chartEngine;
this.chart = chartEngine.chart;
this.zr = this.chart.getZr();
// Event state tracking
this.mouseState = {
isDown: false,
startPoint: null,
currentPoint: null,
dragDistance: 0,
button: null // Track which button was pressed
};
// Event routing
this.eventMode = 'chart'; // 'chart', 'drawing', 'drawingEdit'
// Performance optimization
this.lastMoveTime = 0;
this.moveThrottleMs = 16; // ~60fps max
this.setupMasterHandlers();
}
/**
* Setup single master event handlers
* Replaces all scattered zr.on() calls throughout the codebase
*/
setupMasterHandlers = () => {
console.log('Trace: Setting up centralized event handlers');
// Clear any existing handlers first
this.cleanup();
// Master event handlers - one per event type
this.zr.on('mousedown', this.handleMouseDown);
this.zr.on('mousemove', this.handleMouseMove);
this.zr.on('mouseup', this.handleMouseUp);
this.zr.on('mouseleave', this.handleMouseLeave);
this.zr.on('click', this.handleClick);
this.zr.on('contextmenu', this.handleContextMenu);
// Global fallback for edge cases
document.addEventListener('mouseup', this.handleGlobalMouseUp);
console.log('Trace: Centralized event handlers setup complete');
}
/**
* Master mousedown handler - routes to appropriate subsystem
*/
handleMouseDown = (params) => {
// Update mouse state
this.mouseState.isDown = true;
this.mouseState.startPoint = [params.offsetX, params.offsetY];
this.mouseState.currentPoint = [params.offsetX, params.offsetY];
this.mouseState.dragDistance = 0;
this.mouseState.button = params.event?.button || 0;
// Route based on current mode
switch (this.eventMode) {
case 'drawing':
this.handleDrawingMouseDown(params);
break;
case 'drawingEdit':
this.handleDrawingEditMouseDown(params);
break;
case 'chart':
default:
this.handleChartMouseDown(params);
break;
}
}
/**
* Master mousemove handler with performance throttling
*/
handleMouseMove = (params) => {
// Performance throttling - limit to ~60fps
const now = Date.now();
if (now - this.lastMoveTime < this.moveThrottleMs) {
return;
}
this.lastMoveTime = now;
// Update mouse state
if (this.mouseState.isDown && this.mouseState.startPoint) {
this.mouseState.currentPoint = [params.offsetX, params.offsetY];
this.mouseState.dragDistance = Math.sqrt(
Math.pow(params.offsetX - this.mouseState.startPoint[0], 2) +
Math.pow(params.offsetY - this.mouseState.startPoint[1], 2)
);
}
// Route based on current mode and state
if (this.mouseState.isDown) {
// Mouse is down - this is a drag operation
switch (this.eventMode) {
case 'drawing':
this.handleDrawingMouseMove(params);
break;
case 'drawingEdit':
this.handleDrawingEditMouseMove(params);
break;
case 'chart':
default:
this.handleChartMouseMove(params);
break;
}
} else {
// Mouse is up - this is a hover operation
this.handleMouseHover(params);
}
}
/**
* Master mouseup handler
*/
handleMouseUp = (params) => {
const wasDown = this.mouseState.isDown;
if (wasDown) {
// This was a drag operation ending
switch (this.eventMode) {
case 'drawing':
this.handleDrawingMouseUp(params);
break;
case 'drawingEdit':
this.handleDrawingEditMouseUp(params);
break;
case 'chart':
default:
this.handleChartMouseUp(params);
break;
}
}
// Reset mouse state
this.resetMouseState();
}
/**
* Handle mouse leaving chart area
*/
handleMouseLeave = () => {
const wasDown = this.mouseState.isDown;
if (wasDown) {
// Treat as mouseup
this.handleMouseUp({
offsetX: this.mouseState.currentPoint?.[0] || 0,
offsetY: this.mouseState.currentPoint?.[1] || 0
});
}
this.resetMouseState();
}
/**
* Handle click events
*/
handleClick = (params) => {
// Route based on mode
switch (this.eventMode) {
case 'drawing':
// Handle single-click drawings (horizontal/vertical lines)
if (this.chartEngine.drawingMode === 'horizontalLine' ||
this.chartEngine.drawingMode === 'verticalLine') {
this.handleDrawingClick(params);
}
break;
case 'drawingEdit':
// Handle drawing selection/deselection
this.handleDrawingEditClick(params);
break;
}
}
/**
* Handle context menu (right-click)
*/
handleContextMenu = (params) => {
console.log(`Trace: ContextMenu - Mode: ${this.eventMode}`);
// Prevent default context menu
params.event?.preventDefault();
if (this.eventMode === 'drawingEdit') {
const point = [params.offsetX, params.offsetY];
const drawing = this.chartEngine.getDrawingAtPoint(point);
if (drawing) {
this.chartEngine.showDrawingContextMenu(drawing, params.event.clientX, params.event.clientY);
} else {
this.chartEngine.hideDrawingContextMenu();
}
}
}
/**
* Reset mouse state
*/
resetMouseState = () => {
this.mouseState.isDown = false;
this.mouseState.startPoint = null;
this.mouseState.currentPoint = null;
this.mouseState.dragDistance = 0;
this.mouseState.button = null;
}
/**
* Global mouseup fallback
*/
handleGlobalMouseUp = (event) => {
if (this.mouseState.isDown) {
console.log('Trace: Global mouseup - cleaning up mouse state');
// Calculate relative position if possible
let offsetX = 0, offsetY = 0;
if (this.chart && this.chart.getDom()) {
const rect = this.chart.getDom().getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
}
this.handleMouseUp({ offsetX, offsetY });
}
}
// =============================================================================
// CHART INTERACTION HANDLERS
// =============================================================================
/**
* Handle chart-specific mouse interactions
*/
handleChartMouseDown = (params) => {
// Start interaction tracking for performance
if (this.mouseState.button === 0) { // Left button only
this.chartEngine.startUserInteraction('drag');
}
}
handleChartMouseMove = (params) => {
// Chart drag in progress - ECharts handles the actual dragging
// We just maintain interaction state for performance
}
handleChartMouseUp = (params) => {
// End interaction tracking
this.chartEngine.endUserInteraction();
}
handleMouseHover = (params) => {
const point = [params.offsetX, params.offsetY];
const hoveredDrawing = this.chartEngine.getDrawingAtPoint(point);
// Update cursor and hover state
this.chartEngine.updateCursorForHover(hoveredDrawing);
this.chartEngine.hoveredDrawing = hoveredDrawing;
// Simple mode switching based on hover
if (hoveredDrawing) {
// Mouse is over a drawing - switch to drawingEdit mode
if (this.eventMode !== 'drawingEdit' && this.eventMode !== 'drawing') {
this.setEventMode('drawingEdit');
}
} else {
// Mouse is not over any drawing - switch back to chart mode
if (this.eventMode === 'drawingEdit') {
this.setEventMode('chart');
}
}
}
// =============================================================================
// DRAWING SYSTEM HANDLERS
// =============================================================================
/**
* Drawing-specific event handlers
*/
handleDrawingMouseDown = (params) => {
if (!this.chartEngine.drawingMode || !this.chartEngine.processedData.length) return;
if (this.mouseState.button !== 0) return; // Left button only
// SKIP mousedown/up flow for single-click drawings
if (this.chartEngine.drawingMode === 'horizontalLine' ||
this.chartEngine.drawingMode === 'verticalLine') {
return; // Let these be handled by click event only
}
const point = [params.offsetX, params.offsetY];
const dataPoint = this.chartEngine.pixelToData(point);
this.chartEngine.isDrawing = true;
this.chartEngine.drawingStartPoint = dataPoint;
this.chartEngine.currentDrawing = {
id: `drawing_${Date.now()}`,
type: this.chartEngine.drawingMode,
symbol: this.chartEngine.currentSymbolObject.token,
tradingsymbol: this.chartEngine.currentSymbolObject.tradingsymbol,
start: dataPoint,
end: dataPoint,
style: this.chartEngine.drawingStyles[this.chartEngine.drawingMode] || this.chartEngine.drawingStyles.line,
created: new Date().toISOString()
};
console.log('Trace: Drawing started:', this.chartEngine.currentDrawing.type);
}
handleDrawingMouseMove = (params) => {
if (!this.chartEngine.isDrawing || !this.chartEngine.currentDrawing) return;
const point = [params.offsetX, params.offsetY];
const dataPoint = this.chartEngine.pixelToData(point);
this.chartEngine.currentDrawing.end = dataPoint;
this.chartEngine.updateDrawingPreview();
}
handleDrawingMouseUp = (params) => {
if (!this.chartEngine.isDrawing || !this.chartEngine.currentDrawing) return;
const point = [params.offsetX, params.offsetY];
const dataPoint = this.chartEngine.pixelToData(point);
this.chartEngine.currentDrawing.end = dataPoint;
// Validate drawing size for drag-based drawings
if (['line', 'rectangle', 'circle'].includes(this.chartEngine.currentDrawing.type)) {
if (this.mouseState.dragDistance < 5) {
console.log('Trace: Drawing too small, cancelling');
this.cancelDrawing();
return;
}
}
// Complete drawing
const drawings = this.chartEngine.getCurrentDrawings();
drawings.push(this.chartEngine.currentDrawing);
console.log('Trace: Drawing completed:', this.chartEngine.currentDrawing.type);
this.finishDrawing();
this.chartEngine.updateDrawings();
}
handleDrawingClick = (params) => {
// Handle single-click drawings (horizontal/vertical lines)
const point = [params.offsetX, params.offsetY];
const dataPoint = this.chartEngine.pixelToData(point);
const drawing = {
id: `drawing_${Date.now()}`,
type: this.chartEngine.drawingMode,
symbol: this.chartEngine.currentSymbolObject.token,
tradingsymbol: this.chartEngine.currentSymbolObject.tradingsymbol,
start: dataPoint,
end: dataPoint,
style: this.chartEngine.drawingStyles[this.chartEngine.drawingMode],
created: new Date().toISOString()
};
const drawings = this.chartEngine.getCurrentDrawings();
drawings.push(drawing);
this.finishDrawing();
this.chartEngine.updateDrawings();
}
// =============================================================================
// DRAWING EDIT HANDLERS
// =============================================================================
/**
* Drawing edit mode handlers
*/
handleDrawingEditMouseDown = (params) => {
if (this.mouseState.button !== 0) return; // Left button only
const point = [params.offsetX, params.offsetY];
const drawing = this.chartEngine.getDrawingAtPoint(point);
if (drawing && ['rectangle', 'circle'].includes(drawing.type)) {
this.chartEngine.isDraggingDrawing = true;
this.chartEngine.selectedDrawing = drawing;
this.chartEngine.originalDrawingPosition = {
start: { ...drawing.start },
end: { ...drawing.end }
};
const startPixel = this.chartEngine.chart.convertToPixel({ gridIndex: 0 },
[drawing.start.dataIndex, drawing.start.price]);
this.chartEngine.drawingDragOffset = {
x: point[0] - startPixel[0],
y: point[1] - startPixel[1]
};
// Show drag overlay
const overlay = document.getElementById('drawingDragOverlay');
if (overlay) {
overlay.style.display = 'block';
}
}
}
handleDrawingEditMouseMove = (params) => {
if (!this.chartEngine.isDraggingDrawing) return;
const point = [params.offsetX, params.offsetY];
this.chartEngine.dragDrawing(point);
}
handleDrawingEditMouseUp = (params) => {
if (this.chartEngine.isDraggingDrawing) {
this.chartEngine.stopDraggingDrawing();
}
}
handleDrawingEditClick = (params) => {
// Handle drawing selection/deselection
const point = [params.offsetX, params.offsetY];
const drawing = this.chartEngine.getDrawingAtPoint(point);
if (drawing) {
console.log('Trace: Drawing selected:', drawing.id);
this.chartEngine.selectedDrawing = drawing;
} else {
console.log('Trace: Drawing deselected');
this.chartEngine.selectedDrawing = null;
}
}
// =============================================================================
// DRAWING HELPERS
// =============================================================================
/**
* Cancel current drawing operation
*/
cancelDrawing = () => {
this.chartEngine.isDrawing = false;
this.chartEngine.currentDrawing = null;
this.chartEngine.drawingStartPoint = null;
this.chartEngine.updateDrawings();
}
/**
* Finish current drawing operation
*/
finishDrawing = () => {
this.chartEngine.isDrawing = false;
this.chartEngine.currentDrawing = null;
this.chartEngine.drawingStartPoint = null;
setTimeout(() => {
this.chartEngine.setDrawingMode(null);
}, 200); // Allow UI to update before clearing
// Clear drawing tool UI
document.querySelectorAll('[data-drawing]').forEach(d => {
d.classList.remove('active');
});
}
// =============================================================================
// MODE MANAGEMENT
// =============================================================================
/**
* Switch event handling mode
* @param {string} mode - 'chart', 'drawing', 'drawingEdit'
*/
setEventMode = (mode) => {
if (this.eventMode === mode) return;
console.log(`Trace: Event mode changing from ${this.eventMode} to ${mode}`);
// Cleanup current mode state
this.resetMouseState();
// Cancel any active operations
if (this.chartEngine.isDrawing) {
this.cancelDrawing();
}
if (this.chartEngine.isDraggingDrawing) {
this.chartEngine.stopDraggingDrawing();
}
// Set new mode
const oldMode = this.eventMode;
this.eventMode = mode;
// Update chart interaction settings
this.updateChartInteraction();
// Update cursor
this.updateCursor();
console.log(`Trace: Event mode changed from ${oldMode} to ${mode}`);
}
/**
* Update chart interaction settings based on current mode
*/
updateChartInteraction = () => {
if (!this.chart) return;
try {
const option = this.chart.getOption();
const hasVolume = this.chartEngine.activeIndicators.has('volume') || this.chartEngine.settings.volumeEnabled;
const hasOscillators = Array.from(this.chartEngine.activeIndicators.values()).some(indicator =>
indicator.getGridIndex && indicator.getGridIndex(hasVolume) > 1
);
let xAxisIndices = [0];
if (hasVolume) xAxisIndices.push(1);
if (hasOscillators) xAxisIndices.push(hasVolume ? 2 : 1);
// Enable/disable chart interaction based on mode
const interactionEnabled = this.eventMode === 'chart';
this.chart.setOption({
dataZoom: [{
type: 'inside',
disabled: !interactionEnabled,
xAxisIndex: xAxisIndices,
start: option.dataZoom?.[0]?.start || 70,
end: option.dataZoom?.[0]?.end || 100,
zoomLock: !interactionEnabled,
moveOnMouseMove: interactionEnabled,
moveOnMouseWheel: interactionEnabled,
preventDefaultMouseMove: !interactionEnabled
}]
});
console.log(`Trace: Chart interaction ${interactionEnabled ? 'enabled' : 'disabled'} for mode ${this.eventMode}`);
} catch (error) {
console.error('Trace: Error updating chart interaction:', error);
}
}
/**
* Update cursor based on current mode
*/
updateCursor = () => {
const chartDom = document.getElementById('traceChart');
if (!chartDom) return;
switch (this.eventMode) {
case 'chart':
chartDom.style.cursor = 'default';
break;
case 'drawing':
chartDom.style.cursor = 'crosshair';
break;
case 'drawingEdit':
chartDom.style.cursor = 'default';
break;
}
}
// =============================================================================
// PERFORMANCE MONITORING
// =============================================================================
/**
* Get performance statistics
*/
getPerformanceStats = () => {
return {
eventMode: this.eventMode,
mouseState: { ...this.mouseState },
throttleRate: this.moveThrottleMs,
lastMoveTime: this.lastMoveTime
};
}
/**
* Update performance settings
*/
setPerformanceSettings = (settings) => {
if (settings.throttleMs) {
this.moveThrottleMs = Math.max(8, Math.min(50, settings.throttleMs)); // 8-50ms range
console.log(`Trace: Mouse move throttle set to ${this.moveThrottleMs}ms`);
}
}
// =============================================================================
// CLEANUP
// =============================================================================
/**
* Cleanup all event handlers and state
*/
cleanup = () => {
console.log('Trace: Cleaning up event manager');
if (this.zr) {
this.zr.off('mousedown', this.handleMouseDown);
this.zr.off('mousemove', this.handleMouseMove);
this.zr.off('mouseup', this.handleMouseUp);
this.zr.off('mouseleave', this.handleMouseLeave);
this.zr.off('click', this.handleClick);
this.zr.off('contextmenu', this.handleContextMenu);
}
document.removeEventListener('mouseup', this.handleGlobalMouseUp);
this.resetMouseState();
this.eventMode = 'chart';
console.log('Trace: Event manager cleanup complete');
}
}
/**
* TraceChartEngine - Core Chart Engine
*
* @author Shobhit Srivastava
* @license Apache 2.0
*
* Main chart engine handling chart rendering, indicators, drawings, and interactions.
* Preserves all original functionality while being modularized for library use.
*/
class TraceChartEngine {
constructor(dependencies = {}) {
const {
config = null,
quoteFeed = null,
demoGenerator = null,
marketHours = null,
} = dependencies;
// Inject dependencies immediately
this.config = config;
this.quoteFeed = quoteFeed;
this.demoGenerator = demoGenerator;
this.marketHours = marketHours;
// Chart instance and data
this.chart = null;
this.rawData = [];
this.processedData = [];
this.currentSymbolObject = null;
this.currentTimeframe = '1D';
this.currentChartType = 'candlestick';
// Technical indicators
this.indicatorFactory = dependencies.indicatorFactory;
this.activeIndicators = new Map(); // indicatorId -> indicator instance
// Update and polling
this.updateInterval = null;
this.activeFilter = 'all';
this.isUpdating = false;
this.statusInterval = null;
// API and fallback settings
this.apiEnabled = false;
this.fallbackToDemo = true;
this.lastTickTimestamp = null;
// Drawing tools state
this.drawingMode = null;
this.drawingsPerSymbol = new Map();
this.currentDrawing = null;
this.isDrawing = false;
this.drawingStartPoint = null;
// Drawing styles configuration
this.drawingStyles = {
line: {
stroke: 'rgb(153, 102, 255)',
lineWidth: 2
},
rectangle: {
stroke: 'rgba(0, 137, 208, 1)',
lineWidth: 2,
fill: 'rgba(0, 137, 208, 0.1)'
},
horizontalLine: {
stroke: 'rgb(254, 213, 28)',
lineWidth: 1,
lineDash: [5, 5]
},
verticalLine: {
stroke: 'rgb(22, 127, 57)',
lineWidth: 1,
lineDash: [5, 5]
}
};
// Drawing interaction state
this.hoveredDrawing = null;
this.selectedDrawing = null;
this.isDraggingDrawing = false;
this.drawingDragOffset = null;
this.contextMenuDrawing = null;
// Chart interaction modes
this.interactionMode = 'chart';
this.chartHandlers = null;
this.eventManager = null;
// LTP line configuration for native ECharts implementation
// Uses markLine and y-axis formatting instead of custom graphics
this.ltpLine = {
price: null, // Current LTP price
enabled: this.config?.ltpLine?.enabled ?? true,
style: {
// Native markLine style configuration
stroke: this.config?.ltpLine?.style?.stroke || '#5B9A5D',
lineWidth: this.config?.ltpLine?.style?.lineWidth || 1,
lineDash: this.config?.ltpLine?.style?.lineDash || [4, 2],
opacity: this.config?.ltpLine?.style?.opacity || 0.85
},
label: {
// Native y-axis label highlighting configuration
enabled: this.config?.ltpLine?.label?.enabled ?? true,
backgroundColor: this.config?.ltpLine?.label?.backgroundColor || '#5B9A5D',
textColor: this.config?.ltpLine?.label?.textColor || '#ffffff',
fontSize: this.config?.ltpLine?.label?.fontSize || 11,
fontWeight: this.config?.ltpLine?.label?.fontWeight || 'bold'
}
};
// Interaction state tracking
this.userInteraction = {
isActive: false, // Is user currently interacting?
type: null, // 'drag', 'zoom', 'drawing', null
startTime: null, // When interaction started
pausedUpdates: [] // Queue of updates to apply after interaction
};
// Chart appearance settings - merge with config if available
this.settings = {
theme: 'dark',
backgroundColor: this.config?.theme?.backgroundColor || '#181818',
gridColor: this.config?.theme?.gridColor || '#404040',
bullishColor: this.config?.theme?.bullishColor || '#5B9A5D',
bearishColor: this.config?.theme?.bearishColor || '#E25F5B',
crosshairEnabled: this.config?.features?.crosshairEnabled ?? true,
tooltipEnabled: this.config?.features?.tooltipEnabled ?? true,
volumeEnabled: this.config?.features?.volumeEnabled ?? true,
crosshairColor: this.config?.theme?.crosshairColor || '#BBBBBB',
textColor: this.config?.theme?.textColor || '#BBBBBB'
};
console.log('Trace: Starting initialization with dependencies...');
// Only initialize if we have the necessary dependencies
if (this.config) {
this.init();
} else {
console.warn('Trace: TraceChartEngine created without config - delaying initialization');
}
}
/**
* Start user interaction - pause all updates
* @param {string} type - Type of interaction ('drag', 'zoom', 'drawing')
*/
startUserInteraction = (type = 'drag') => {
if (this.userInteraction.isActive) {
// Already active, just update type if different
if (this.userInteraction.type !== type) {
console.log(`Trace: Interaction type changed from ${this.userInteraction.type} to ${type}`);
this.userInteraction.type = type;
}
return;
}
this.userInteraction.isActive = true;
this.userInteraction.type = type;
this.userInteraction.startTime = Date.now();
this.userInteraction.pausedUpdates = [];
console.log(`Trace: User interaction started (${type}) - pausing updates`);
// Clear any pending animation frames
if (this.chartUpdateTimeout) {
clearTimeout(this.chartUpdateTimeout);
this.chartUpdateTimeout = null;
}
}
/**
* End user interaction - resume updates
* @param {number} delay - Delay before resuming updates (default: 100ms)
*/
endUserInteraction = () => {
if (!this.userInteraction.isActive) return; // Not active
const duration = Date.now() - this.userInteraction.startTime;
console.log(`Trace: User interaction ended (${this.userInteraction.type}) after ${duration}ms - resuming updates`);
this.userInteraction.isActive = false;
this.userInteraction.type = null;
this.userInteraction.startTime = null;
// Apply any queued updates immediately (no delay)
this.applyQueuedUpdates();
}
/**
* Apply updates that were queued during interaction
*/
applyQueuedUpdates = () => {
if (this.userInteraction.pausedUpdates.length === 0) {
console.log('Trace: No queued updates to apply');
return;
}
console.log(`Trace: Applying ${this.userInteraction.pausedUpdates.length} queued updates`);
// Analyze queued updates for optimal batching
let hasChartUpdate = false;
let hasLTPUpdate = false;
let latestLTPPrice = null;
let updateCount = {
chart: 0,
ltp: 0,
indicators: 0
};
this.userInteraction.pausedUpdates.forEach(update => {
updateCount[update.type] = (updateCount[update.type] || 0) + 1;
switch (update.type) {
case 'chart':
hasChartUpdate = true;
break;
case 'ltp':
hasLTPUpdate = true;
latestLTPPrice = update.price; // Only keep latest price
break;
case 'indicators':
hasChartUpdate = true; // Indicators are updated as part of chart update
break;
}
});
console.log('Trace: Update summary:', updateCount);
// Apply batched updates efficiently
if (hasChartUpdate) {
console.log('Trace: Applying batched chart update');
this.updateChart();
}
// LTP updates are now part of chart update (via markLine)
if (hasLTPUpdate && latestLTPPrice && this.ltpLine.enabled) {
this.ltpLine.price = latestLTPPrice;
}
// Clear queue
this.userInteraction.pausedUpdates = [];
console.log('Trace: All queued updates applied and cleared');
}
/**
* Queue an update to be applied after interaction ends
* @param {string} type - Update type ('chart', 'ltp', 'indicators')
* @param {Object} data - Update data
*/
queueUpdate = (type, data = {}) => {
if (!this.userInteraction.isActive) {
// Not in interaction mode, apply immediately
return false;
}
// Add to queue (limit queue size to prevent memory issues)
this.userInteraction.pausedUpdates.push({
type,
timestamp: Date.now(),
...data
});
// Keep only last 10 updates to prevent memory buildup
if (this.userInteraction.pausedUpdates.length > 10) {
this.userInteraction.pausedUpdates = this.userInteraction.pausedUpdates.slice(-10);
}
return true; // Update was queued
}
/**
* Enable/disable LTP line display
* @param {boolean} enabled - Whether to show LTP line
*/
setLTPLineEnabled = (enabled) => {
this.ltpLine.enabled = enabled;
console.log('Trace: LTP line', enabled ? 'enabled' : 'disabled');
// Just trigger chart update - markLine will be included/excluded automatically
if (this.processedData.length > 0) {
this.updateChart();
}
}
/**
* Configure LTP line styling
* @param {Object} style - Style configuration
*/
setLTPLineStyle = (style) => {
this.ltpLine.style = { ...this.ltpLine.style, ...style };
console.log('Trace: LTP line style updated:', style);
// Update chart to apply new markLine styles
if (this.ltpLine.price && this.ltpLine.enabled) {
this.updateChart();
}
}
/**
* Configure LTP label styling
* @param {Object} labelConfig - Label configuration
*/
setLTPLabelStyle = (labelConfig) => {
this.ltpLine.label = { ...this.ltpLine.label, ...labelConfig };
console.log('Trace: LTP label style updated:', labelConfig);
// Update chart to apply new y-axis label formatting
if (this.ltpLine.price && this.ltpLine.enabled) {
this.updateChart();
}
}
/**
* Update LTP line using native ECharts markLine
* Called on every tick to maintain real-time price line
*
*/
updateLTPLine = (newPrice) => {
// Skip update if user is interacting
if (this.userInteraction.isActive) {
this.queueUpdate('ltp', { price: newPrice });
return;
}
if (!this.chart || !newPrice || !this.ltpLine.enabled) {
return;
}
try {
// Just store the price - markLine will be updated in next chart render
const oldPrice = this.ltpLine.price;
this.ltpLine.price = newPrice;
console.log(`Trace: LTP updated from ${oldPrice} to ${newPrice} (using native markLine)`);
// The markLine will be automatically updated in the next chart update cycle
} catch (error) {
console.error('Trace: Error updating LTP line:', error);
// Graceful fallback - continue without LTP line
this.ltpLine.enabled = false;
}
}
// Get current symbol's drawings
getCurrentDrawings = () => {
if (!this.currentSymbolObject) return [];
const token = this.currentSymbolObject.token;
if (!this.drawingsPerSymbol.has(token)) {
this.drawingsPerSymbol.set(token, []);
}
return this.drawingsPerSymbol.get(token);
}
setDrawingMode = (mode) => {
this.drawingMode = mode;
if (mode) {
this.setInteractionMode('drawing');
} else {
// If coming from drawingEdit, reset to chart mode
if (this.interactionMode !== 'chart') {
this.setInteractionMode('chart');
} else {
this.setInteractionMode('drawingEdit');
}
}
}
initDrawingSystem = () => {
this.setInteractionMode('drawingEdit');
this.chart.getDom().addEventListener('contextmenu', (e) => {
e.preventDefault();
if (this.interactionMode !== 'drawingEdit') return;
const rect = this.chart.getDom().getBoundingClientRect();
const point = [e.clientX - rect.left, e.clientY - rect.top];
const drawing = this.getDrawingAtPoint(point);
if (drawing) {
this.showDrawingContextMenu(drawing, e.clientX, e.clientY);
} else {
this.hideDrawingContextMenu();
}
});
document.addEventListener('click', () => {
this.hideDrawingContextMenu();
});
}
getFallbackDataPoint = () => {
const lastIndex = this.processedData.length - 1;
return {
dataIndex: lastIndex,
timestamp: this.processedData[lastIndex].timestamp,
price: this.processedData[lastIndex].close
};
}
pixelToData = (pixelPoint) => {
try {
const pointInGrid = this.chart.convertFromPixel({ gridIndex: 0 }, pixelPoint);
if (!pointInGrid) {
console.error('Could not convert pixel to data coordinates');
return this.getFallbackDataPoint();
}
const dataIndex = Math.round(pointInGrid[0]);
const clampedIndex = Math.max(0, Math.min(dataIndex, this.processedData.length - 1));
const price = pointInGrid[1];
return {
dataIndex: clampedIndex,
timestamp: this.processedData[clampedIndex].timestamp,
price: price
};
} catch (error) {
console.error('Error in pixelToData:', error);
return this.getFallbackDataPoint();
}
}
updateDrawingPreview = () => {
if (!this.currentDrawing) return;
const existingDrawings = this.buildAllDrawingGraphics();
const tempGraphic = this.buildDrawingGraphic(this.currentDrawing);
if (tempGraphic) {
tempGraphic.id = 'drawing_preview';
tempGraphic.style = {
...tempGraphic.style,
opacity: 0.7
};
this.chart.setOption({
graphic: [...existingDrawings, tempGraphic]
});
}
}
refreshChartAndDrawings = () => {
this.updateChart();
this.updateDrawings();
}
buildDrawingGraphic = (drawing) => {
try {
const startIndex = drawing.start.dataIndex;
const endIndex = drawing.end.dataIndex;
if (startIndex < 0 || startIndex >= this.processedData.length) {
console.error('Invalid start index:', startIndex);
return null;
}
if (endIndex < 0 || endIndex >= this.processedData.length) {
console.error('Invalid end index:', endIndex);
return null;
}
const startPixel = this.chart.convertToPixel({ gridIndex: 0 }, [startIndex, drawing.start.price]);
const endPixel = this.chart.convertToPixel({ gridIndex: 0 }, [endIndex, drawing.end.price]);
const option = this.chart.getOption();
const grid = option.grid[0];
const chartDom = this.chart.getDom();
const rect = chartDom.getBoundingClientRect();
const gridLeft = parseFloat(grid.left.replace('%', '')) * rect.width / 100;
const gridRight = parseFloat(grid.right.replace('%', '')) * rect.width / 100;
const gridTop = parseFloat(grid.top.replace('%', '')) * rect.height / 100;
const gridBottom = parseFloat(grid.bottom.replace('%', '')) * rect.height / 100;
const gridWidth = rect.width - gridLeft - gridRight;
const gridHeight = rect.height - gridTop - gridBottom;
const defaultStyles = this.drawingStyles[drawing.type] || this.drawingStyles.line;
const style = drawing.style || defaultStyles;
switch (drawing.type) {
case 'line':
return {
type: 'line',
id: drawing.id,
shape: {
x1: startPixel[0],
y1: startPixel[1],
x2: endPixel[0],
y2: endPixel[1]
},
style: {
stroke: style.stroke || '#00ff00',
lineWidth: style.lineWidth || 2
},
z: 100
};
case 'rectangle':
return {
type: 'rect',
id: drawing.id,
shape: {
x: Math.min(startPixel[0], endPixel[0]),
y: Math.min(startPixel[1], endPixel[1]),
width: Math.abs(endPixel[0] - startPixel[0]),
height: Math.abs(endPixel[1] - startPixel[1])
},
style: {
stroke: style.stroke || '#00ff00',
lineWidth: style.lineWidth || 2,
fill: style.fill || 'rgba(0, 255, 0, 0.1)'
},
z: 100
};
case 'circle':
const radius = Math.sqrt(
Math.pow(endPixel[0] - startPixel[0], 2) +
Math.pow(endPixel[1] - startPixel[1], 2)
);
return {
type: 'circle',
id: drawing.id,
shape: {
cx: startPixel[0],
cy: startPixel[1],
r: radius
},
style: {
stroke: style.stroke || 'rgb(153, 102, 255)',
lineWidth: style.lineWidth || 2,
fill: style.fill || 'rgba(153, 102, 255, 0.1)'
},
z: 100
};
case 'horizontalLine':
return {
type: 'line',
id: drawing.id,
shape: {
x1: gridLeft,
y1: startPixel[1],
x2: gridLeft + gridWidth,
y2: startPixel[1]
},
style: {
stroke: style.stroke || '#ffff00',
lineWidth: style.lineWidth || 2,
lineDash: style.lineDash || [5, 5]
},
z: 100
};
case 'verticalLine':
return {
type: 'line',
id: drawing.id,
shape: {
x1: startPixel[0],
y1: gridTop,
x2: startPixel[0],
y2: gridTop + gridHeight
},
style: {
stroke: style.stroke || '#ffff00',
lineWidth: style.lineWidth || 2,
lineDash: style.lineDash || [5, 5]
},
z: 100
};
default:
console.warn('Unknown drawing type:', drawing.type);
return null;
}
} catch (error) {
console.error('Error building drawing graphic:', error, drawing);
return null;
}
}
buildAllDrawingGraphics = () => {
const drawings = this.getCurrentDrawings();
return drawings
.map(drawing => this.buildDrawingGraphic(drawing))
.filter(graphic => graphic !== null);
}
clearDrawings = () => {
if (!this.currentSymbolObject) return;
const token = this.currentSymbolObject.token;
this.drawingsPerSymbol.set(token, []);
this.updateDrawings();
console.log('Trace: Cleared all drawings for', this.currentSymbolObject.tradingsymbol);
}
getDrawingInfo = () => {
const allDrawings = [];
this.drawingsPerSymbol.forEach((drawings, token) => {
drawings.forEach(drawing => {
allDrawings.push({
symbol: drawing.tradingsymbol || token,
type: drawing.type,
created: drawing.created
});
});
});
return allDrawings;
}
exportDrawings = () => {
const data = {
version: 1,
drawings: Object.fromEntries(this.drawingsPerSymbol),
exported: new Date().toISOString()
};
return JSON.stringify(data);
}
importDrawings = (jsonData) => {
try {
const data = JSON.parse(jsonData);
if (data.version !== 1) {
throw new Error('Unsupported drawing data version');
}
this.drawingsPerSymbol = new Map(Object.entries(data.drawings));
this.updateChart();
console.log('Imported drawings for', this.drawingsPerSymbol.size, 'symbols');
return true;
} catch (error) {
console.error('Failed to import drawings:', error);
return false;
}
}
/**
* Initialize chart engine - sets up DOM, loads data, and starts real-time updates
* Main initialization flow for the entire chart system
*/
init = async () => {
try {
console.log('Trace: Initializing with config:', this.config ? 'Available' : 'Missing');
await this.waitForDOM();
let params = await window.chartUtils.parseQueryParam(window.location.href);
window.chartUtils.setGlobals(params);
window.chartGlobalVars.stateAPI = window.parent?.stateAPI || window.chartGlobalVars.stateAPI;
this.setDefaultSymbol();
this.initChart();
await this.loadHistoricalData();
this.updateChart();
this.setupEventListeners();
this.startRealTimeUpdates();
this.initializeUIState();
document.getElementById('loadingIndicator').style.display = 'none';
this.updateStatus('success');
console.log('Trace: Initialization complete');
} catch (error) {
console.error('Trace: Error:', error);
this.showError(`Failed to initialize chart: ${error.message}`);
}
}
/**
* Set default symbol using instrument manager or fallback to demo data
*/
setDefaultSymbol = () => {
if (window?.chartGlobalVars.stateAPI?.instrumentManager) {
const defaultSymbol = window?.chartGlobalVars.stateAPI?.instrumentManager.get('RELIANCE', 'NSE');
if (defaultSymbol) {
defaultSymbol.token ??= defaultSymbol.instrumentToken || 'RELIANCE_NSE';
this.currentSymbolObject = defaultSymbol;
console.log('Trace: Set default symbol:', defaultSymbol.tradingsymbol, 'with basePrice:', defaultSymbol.basePrice);
return;
}
}
// Fallback to demo data if instrument manager not available
if (this.demoGenerator) {
const defaultDemoSymbol = this.demoGenerator.getAllSymbols()[0];
if (defaultDemoSymbol) {
this.currentSymbolObject = defaultDemoSymbol;
console.log('Trace: Set fallback demo symbol:', defaultDemoSymbol.tradingsymbol, 'with basePrice:', defaultDemoSymbol.basePrice);
return;
}
}
console.warn('Trace: No symbol source available, using hardcoded fallback');
// Last resort hardcoded fallback
this.currentSymbolObject = {
token: 'RELIANCE_NSE',
tradingsymbol: 'RELIANCE',
exchange: 'NSE',
segment: 'NSE',
basePrice: 2450
};
}
waitForDOM = () => {
return new Promise((resolve) => {
if (document.getElementById('traceChart')) {
resolve();
} else {
setTimeout(() => this.waitForDOM().then(resolve), 100);
}
});
}
getGridConfig = () => {
const hasVolume = this.activeIndicators.has('volume') || this.settings.volumeEnabled;
// Pass actual hasVolume state to getGridIndex (was false)
const hasOscillators = Array.from(this.activeIndicators.values()).some(indicator =>
indicator.getGridIndex && indicator.getGridIndex(hasVolume) > 1
);
if (hasOscillators && hasVolume) {
return [
{ left: '3%', right: '3%', top: '10%', bottom: '35%', containLabel: true }, // Main chart
{ left: '3%', right: '3%', top: '70%', bottom: '25%', containLabel: true }, // Volume
{ left: '3%', right: '3%', top: '80%', bottom: '5%', containLabel: true } // Oscillators
];
} else if (hasOscillators && !hasVolume) {
return [
{ left: '3%', right: '3%', top: '10%', bottom: '25%', containLabel: true }, // Main chart
{ left: '3%', right: '3%', top: '80%', bottom: '5%', containLabel: true } // Oscillators
];
} else if (!hasOscillators && hasVolume) {
return [
{ left: '3%', right: '3%', top: '10%', bottom: '25%', containLabel: true }, // Main chart
{ left: '3%', right: '3%', top: '80%', bottom: '5%', containLabel: true } // Volume
];
} else {
return [
{ left: '3%', right: '3%', top: '10%', bottom: '5%', containLabel: true } // Main cha