/** * Drawing Tools for Lightweight Charts * Implements professional trading chart drawing primitives */ export class DrawingManager { constructor(dashboard, container) { this.dashboard = dashboard; this.chart = dashboard.chart; this.series = dashboard.candleSeries; this.container = container; this.drawings = []; this.activeTool = null; this.currentDrawing = null; this.selectedDrawing = null; this.dragMode = null; this.isMouseDown = false; // Default Settings this.defaults = { trend_line: { color: '#2962ff', width: 2, style: 0, // 0: Solid, 1: Dashed, 2: Dotted opacity: 100, text: '', textColor: '#2962ff', fontSize: 14, bold: false, italic: false, alignVert: 'top', alignHorz: 'left' }, horizontal_line: { color: '#2962ff', width: 2, style: 0, opacity: 100, text: '', textColor: '#2962ff', fontSize: 14, bold: false, italic: false, alignVert: 'top', alignHorz: 'left' }, vertical_line: { color: '#2962ff', width: 2, style: 0, opacity: 100, text: '', textColor: '#2962ff', fontSize: 14, bold: false, italic: false, alignVert: 'top', alignHorz: 'left' } }; // Dragging offsets this.dragStartPos = null; this.startP1 = null; this.startP2 = null; this.startPrice = null; this.startTime = null; this.init(); this.initSettingsPanel(); } init() { const container = this.container; // Mouse Events container.addEventListener('mousedown', (e) => this.handleMouseDown(e)); container.addEventListener('dblclick', (e) => { const pos = this.getMousePos(e); const hit = this.findHit(pos.x, pos.y); if (hit) { this.selectedDrawing = hit.drawing; // Only open panel for supported types if (['trend_line', 'ray', 'rectangle', 'horizontal_line', 'vertical_line'].includes(hit.drawing.type)) { this.toggleSettingsPanel(true); } this.update(); } }); window.addEventListener('mousemove', (e) => this.handleMouseMove(e)); window.addEventListener('mouseup', (e) => this.handleMouseUp(e)); // Touch Events container.addEventListener('touchstart', (e) => { if (this.activeTool || this.selectedDrawing) e.preventDefault(); this.handleMouseDown(this.touchToMouseEvent(e)); }, { passive: false }); window.addEventListener('touchmove', (e) => { if (this.activeTool || (this.selectedDrawing && this.isMouseDown)) e.preventDefault(); this.handleMouseMove(this.touchToMouseEvent(e)); }, { passive: false }); window.addEventListener('touchend', (e) => { this.handleMouseUp(); }); window.addEventListener('keydown', (e) => { if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedDrawing) { this.drawings = this.drawings.filter(d => d !== this.selectedDrawing); this.selectedDrawing = null; this.update(); } }); this.drawingPrimitive = { attached: (param) => { this.requestUpdate = param.requestUpdate; }, detached: () => { this.requestUpdate = undefined; }, updateAllViews: () => {}, paneViews: () => [{ renderer: () => ({ draw: (target) => this.render(target) }) }], priceAxisViews: () => this.getPriceAxisViews(), timeAxisViews: () => this.getTimeAxisViews() }; this.series.attachPrimitive(this.drawingPrimitive); } getPriceAxisViews() { const views = []; const drawingsToShow = []; if (this.currentDrawing && this.currentDrawing.type === 'measure') drawingsToShow.push(this.currentDrawing); if (this.selectedDrawing && this.selectedDrawing.type === 'measure') drawingsToShow.push(this.selectedDrawing); drawingsToShow.forEach(d => { const isUp = d.p2.price >= d.p1.price; const color = isUp ? '#26a69a' : '#ef5350'; [d.p1.price, d.p2.price].forEach(price => { const coord = this.series.priceToCoordinate(price); if (coord !== null) { views.push({ coordinate: () => coord, text: () => price.toFixed(2), textColor: () => '#ffffff', backColor: () => color, visible: () => true, }); } }); }); return views; } getTimeAxisViews() { const views = []; const drawingsToShow = []; if (this.currentDrawing && this.currentDrawing.type === 'measure') drawingsToShow.push(this.currentDrawing); if (this.selectedDrawing && this.selectedDrawing.type === 'measure') drawingsToShow.push(this.selectedDrawing); drawingsToShow.forEach(d => { const isUp = d.p2.price >= d.p1.price; const color = isUp ? '#26a69a' : '#ef5350'; [d.p1.time, d.p2.time].forEach(time => { const coord = this.timeToX(time); if (coord !== null) { views.push({ coordinate: () => coord, text: () => { const date = new Date(time * 1000); const day = date.getDate().toString().padStart(2, '0'); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = months[date.getMonth()]; const year = date.getFullYear().toString().slice(-2); const hours = date.getHours().toString().padStart(2, '0'); const mins = date.getMinutes().toString().padStart(2, '0'); return `${day} ${month} '${year} ${hours}:${mins}`; }, textColor: () => '#ffffff', backColor: () => color, visible: () => true, }); } }); }); return views; } touchToMouseEvent(e) { const touch = e.touches[0] || e.changedTouches[0]; return { clientX: touch.clientX, clientY: touch.clientY, shiftKey: e.shiftKey, preventDefault: () => e.preventDefault() }; } updateChartInteractions() { const isInteracting = this.activeTool !== null || this.currentDrawing !== null || (this.selectedDrawing !== null && this.isMouseDown); this.chart.applyOptions({ handleScroll: { mouseWheel: !isInteracting, pressedMouseMove: !isInteracting, horzTouchDrag: !isInteracting, vertTouchDrag: !isInteracting, }, handleScale: { axisPressedMouseMove: !isInteracting, mouseWheel: !isInteracting, pinch: !isInteracting, } }); } setTool(tool, event) { // Shift + Click on trend_line button opens settings if (tool === 'trend_line' && event && event.shiftKey) { this.toggleSettingsPanel(true); return; } this.activeTool = tool; if (tool === 'cursor') { this.activeTool = null; document.body.style.cursor = 'default'; } else if (tool === 'clear') { this.drawings = []; this.selectedDrawing = null; this.activeTool = null; this.update(); } else { document.body.style.cursor = 'crosshair'; this.selectedDrawing = null; } this.updateChartInteractions(); this.update(); } getIntervalSeconds() { const interval = this.dashboard.currentInterval; const unit = interval.slice(-1); // Keep case to distinguish 'm' and 'M' const value = parseInt(interval.slice(0, -1)); switch (unit) { case 'm': return value * 60; case 'h': return value * 3600; case 'd': return value * 86400; case 'w': return value * 604800; case 'M': return value * 2592000; default: return 60; } } getMousePos(e) { const rect = this.container.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const timeScale = this.chart.timeScale(); const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || []; let time = timeScale.coordinateToTime(x); // Handle whitespace / future drawing if (candleData.length > 0) { const logical = timeScale.coordinateToLogical(x); const lastIdx = candleData.length - 1; // If coordinateToTime returns null OR logical index > last index (future) if (logical !== null && (time === null || logical > lastIdx)) { const lastCandle = candleData[lastIdx]; const intervalSec = this.getIntervalSeconds(); const offset = logical - lastIdx; // Calculate time based on logical offset from the last candle time = lastCandle.time + Math.round(offset * intervalSec); } else if (logical !== null && logical < 0) { // Handle past whitespace (if any) const firstCandle = candleData[0]; const intervalSec = this.getIntervalSeconds(); time = firstCandle.time + Math.round(logical * intervalSec); } } const price = this.series.coordinateToPrice(y); return { x, y, time, price }; } handleMouseDown(e) { const pos = this.getMousePos(e); this.isMouseDown = true; this.dragStartPos = pos; // Shift + Click shortcut for Measurement Tool if (e.shiftKey && !this.activeTool) { this.setTool('measure'); this.currentDrawing = { type: 'measure', p1: { time: pos.time, price: pos.price }, p2: { time: pos.time, price: pos.price }, color: '#26a69a' }; this.update(); return; } // Click-Move-Click logic for Measurement Tool if (this.activeTool === 'measure') { if (this.currentDrawing) { // 2nd Click: Finish this.drawings.push(this.currentDrawing); this.currentDrawing = null; this.setTool(null); this.isMouseDown = false; this.update(); return; } else { // 1st Click: Start this.currentDrawing = { type: 'measure', p1: { time: pos.time, price: pos.price }, p2: { time: pos.time, price: pos.price }, color: '#26a69a', labelOffset: { x: 0, y: 0 } }; this.update(); return; } } if (!this.activeTool) { const hit = this.findHit(pos.x, pos.y); if (hit) { this.selectedDrawing = hit.drawing; this.dragMode = hit.part; const d = this.selectedDrawing; if (d.p1) this.startP1 = { ...d.p1 }; if (d.p2) this.startP2 = { ...d.p2 }; if (d.price !== undefined) this.startPrice = d.price; if (d.time !== undefined) this.startTime = d.time; this.updateChartInteractions(); this.update(); return; } else { this.selectedDrawing = null; this.dragMode = null; } } if (!this.activeTool || pos.time === null || pos.price === null) { this.update(); return; } const color = this.defaults.trend_line.color; const p1 = { time: pos.time, price: pos.price }; const p2 = { time: pos.time, price: pos.price }; const defs = this.defaults.trend_line; if (this.activeTool === 'trend_line' || this.activeTool === 'ray' || this.activeTool === 'rectangle') { this.currentDrawing = { type: this.activeTool, p1, p2, color: defs.color, width: defs.width, style: defs.style, opacity: defs.opacity, text: defs.text, textColor: defs.textColor, fontSize: defs.fontSize, bold: defs.bold, italic: defs.italic, alignVert: defs.alignVert, alignHorz: defs.alignHorz }; } else if (this.activeTool === 'horizontal_line') { const defs = this.defaults.horizontal_line; this.drawings.push({ type: 'horizontal_line', price: pos.price, color: defs.color, width: defs.width, style: defs.style, opacity: defs.opacity }); this.setTool(null); } else if (this.activeTool === 'vertical_line') { const defs = this.defaults.vertical_line; this.drawings.push({ type: 'vertical_line', time: pos.time, color: defs.color, width: defs.width, style: defs.style, opacity: defs.opacity }); this.setTool(null); } else if (this.activeTool === 'fib_retracement') { this.currentDrawing = { type: 'fib_retracement', p1, p2, color: '#fb8c00' }; } else if (this.activeTool === 'arrow_up' || this.activeTool === 'arrow_down') { this.drawings.push({ type: 'arrow', time: pos.time, price: pos.price, direction: this.activeTool === 'arrow_up' ? 'up' : 'down', color: this.activeTool === 'arrow_up' ? '#26a69a' : '#ef5350' }); this.setTool(null); } else if (this.activeTool === 'text') { const text = prompt('Enter text:'); if (text) this.drawings.push({ type: 'text', time: pos.time, price: pos.price, text, color: '#ffffff' }); this.setTool(null); } this.update(); } handleMouseMove(e) { const pos = this.getMousePos(e); // Hover cursor logic if (!this.isMouseDown && !this.activeTool) { const hit = this.findHit(pos.x, pos.y); if (hit) { this.container.style.cursor = hit.part === 'label' ? 'move' : 'pointer'; } else { this.container.style.cursor = 'default'; } } // Track measurement even if mouse is up (Click-Move-Click) if (this.currentDrawing && this.currentDrawing.type === 'measure') { if (pos.time !== null) { this.currentDrawing.p2 = { time: pos.time, price: pos.price }; this.update(); } return; } if (!this.isMouseDown) return; if (this.selectedDrawing && this.dragMode) { if (pos.time === null || pos.price === null) return; const d = this.selectedDrawing; // Safety check for dragStartPos if (!this.dragStartPos) { this.dragStartPos = pos; return; } if (this.dragMode === 'label') { // Free movement of ONLY the label field const dx = pos.x - this.dragStartPos.x; const dy = pos.y - this.dragStartPos.y; if (!d.labelOffset) d.labelOffset = { x: 0, y: 0 }; d.labelOffset.x += dx; d.labelOffset.y += dy; this.dragStartPos = pos; this.update(); return; // Critical: exit here so we don't move measurement points } if (this.dragMode === 'label') { const dx = pos.x - this.dragStartPos.x; const dy = pos.y - this.dragStartPos.y; if (!d.labelOffset) d.labelOffset = { x: 0, y: 0 }; d.labelOffset.x += dx; d.labelOffset.y += dy; this.dragStartPos = pos; this.update(); return; } if (this.dragMode === 'p1') { d.p1 = { time: pos.time, price: pos.price }; } else if (this.dragMode === 'p2') { d.p2 = { time: pos.time, price: pos.price }; } else if (this.dragMode === 'all') { let timeDiff = pos.time - this.dragStartPos.time; const priceDiff = pos.price - this.dragStartPos.price; // If timeDiff is NaN (e.g., dragged into whitespace on high TF), estimate it if (isNaN(timeDiff) || pos.time === null) { const timeScale = this.chart.timeScale(); const startLogical = timeScale.coordinateToLogical(this.dragStartPos.x); const currentLogical = timeScale.coordinateToLogical(pos.x); if (startLogical !== null && currentLogical !== null) { const logicalDiff = currentLogical - startLogical; timeDiff = logicalDiff * this.getIntervalSeconds(); } } if (!isNaN(timeDiff) && !isNaN(priceDiff)) { if (d.p1 && d.p2) { d.p1.time = this.startP1.time + timeDiff; d.p1.price = this.startP1.price + priceDiff; d.p2.time = this.startP2.time + timeDiff; d.p2.price = this.startP2.price + priceDiff; } else if (d.time !== undefined && d.price !== undefined) { d.time = this.startTime + timeDiff; d.price = this.startPrice + priceDiff; } else if (d.price !== undefined) { d.price = this.startPrice + priceDiff; } else if (d.time !== undefined) { d.time = this.startTime + timeDiff; } } } else if (this.dragMode === 'price') { d.price = pos.price; } else if (this.dragMode === 'time') { d.time = pos.time; } this.update(); return; } if (this.currentDrawing && pos.time !== null) { this.currentDrawing.p2 = { time: pos.time, price: pos.price }; this.update(); } } handleMouseUp() { if (this.currentDrawing && this.currentDrawing.type === 'measure') { this.isMouseDown = false; this.updateChartInteractions(); this.update(); return; } if (this.currentDrawing) { this.drawings.push(this.currentDrawing); this.currentDrawing = null; this.setTool(null); } this.dragMode = null; this.isMouseDown = false; this.updateChartInteractions(); this.update(); } findHit(x, y) { const threshold = 10; for (let i = this.drawings.length - 1; i >= 0; i--) { const d = this.drawings[i]; // Check for label hit first (highest z-order) if (d.type === 'measure' && d.labelPos) { if (x >= d.labelPos.x && x <= d.labelPos.x + d.labelPos.width && y >= d.labelPos.y && y <= d.labelPos.y + d.labelPos.height) { return { drawing: d, part: 'label' }; } } if (d.p1 && d.p2) { const x1 = this.timeToX(d.p1.time); const y1 = this.series.priceToCoordinate(d.p1.price); const x2 = this.timeToX(d.p2.time); const y2 = this.series.priceToCoordinate(d.p2.price); if (x1 === null || y1 === null || x2 === null || y2 === null) continue; if (Math.hypot(x - x1, y - y1) < threshold) return { drawing: d, part: 'p1' }; if (Math.hypot(x - x2, y - y2) < threshold) return { drawing: d, part: 'p2' }; if (d.type === 'rectangle' || d.type === 'measure') { if (x >= Math.min(x1, x2) && x <= Math.max(x1, x2) && y >= Math.min(y1, y2) && y <= Math.max(y1, y2)) return { drawing: d, part: 'all' }; } else { const dist = this.distToSegment({ x, y }, { x: x1, y: y1 }, { x: x2, y: y2 }); if (dist < threshold) return { drawing: d, part: 'all' }; } } else if (d.price !== undefined && d.type === 'horizontal_line') { const dy = this.series.priceToCoordinate(d.price); if (dy !== null && Math.abs(y - dy) < threshold) { if (d.text && d.labelPos) { if (x >= d.labelPos.x && x <= d.labelPos.x + d.labelPos.width && y >= d.labelPos.y && y <= d.labelPos.y + d.labelPos.height) { return { drawing: d, part: 'label' }; } } return { drawing: d, part: 'all' }; } } else if (d.time !== undefined && d.type === 'vertical_line') { const dx = this.timeToX(d.time); if (dx !== null && Math.abs(x - dx) < threshold) { if (d.text && d.labelPos) { if (x >= d.labelPos.x && x <= d.labelPos.x + d.labelPos.width && y >= d.labelPos.y && y <= d.labelPos.y + d.labelPos.height) { return { drawing: d, part: 'label' }; } } return { drawing: d, part: 'all' }; } } else if (d.time !== undefined && d.price !== undefined) { const dx = this.timeToX(d.time); const dy = this.series.priceToCoordinate(d.price); if (dx !== null && dy !== null && Math.hypot(x - dx, y - dy) < threshold) return { drawing: d, part: 'all' }; } } return null; } distToSegment(p, v, w) { const l2 = Math.pow(v.x - w.x, 2) + Math.pow(v.y - w.y, 2); if (l2 === 0) return Math.hypot(p.x - v.x, p.y - v.y); let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; t = Math.max(0, Math.min(1, t)); return Math.hypot(p.x - (v.x + t * (w.x - v.x)), p.y - (v.y + t * (w.y - v.y))); } update() { if (this.requestUpdate) this.requestUpdate(); } snapToNearestTime(time) { if (time === null) return null; const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || []; if (candleData.length === 0) return time; const firstCandle = candleData[0]; const lastCandle = candleData[candleData.length - 1]; // Handle future whitespace if (time > lastCandle.time) { const intervalSec = this.getIntervalSeconds(); const offset = Math.round((time - lastCandle.time) / intervalSec); return lastCandle.time + (offset * intervalSec); } // Handle past whitespace if (time < firstCandle.time) { const intervalSec = this.getIntervalSeconds(); const offset = Math.round((firstCandle.time - time) / intervalSec); return firstCandle.time - (offset * intervalSec); } // Find nearest timestamp in current data // Since data is sorted, we can be efficient let nearest = candleData[0].time; let minDiff = Math.abs(time - nearest); // Optimization: check if it's already a match if (candleData.find(c => c.time === time)) return time; for (let i = 1; i < candleData.length; i++) { const diff = Math.abs(time - candleData[i].time); if (diff < minDiff) { minDiff = diff; nearest = candleData[i].time; } else if (diff > minDiff) { // Since it's sorted, once diff starts increasing, we've found the closest break; } } return nearest; } timeToX(time) { if (time === null) return null; const timeScale = this.chart.timeScale(); const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || []; if (candleData.length === 0) return timeScale.timeToCoordinate(time); // Native check (exact match) const nativeCoord = timeScale.timeToCoordinate(time); if (nativeCoord !== null) return nativeCoord; const firstCandle = candleData[0]; const lastCandle = candleData[candleData.length - 1]; const intervalSec = this.getIntervalSeconds(); // 1. Whitespace: Future if (time > lastCandle.time) { const lastIdx = candleData.length - 1; const offset = (time - lastCandle.time) / intervalSec; return timeScale.logicalToCoordinate(lastIdx + offset); } // 2. Whitespace: Past if (time < firstCandle.time) { const offset = (time - firstCandle.time) / intervalSec; return timeScale.logicalToCoordinate(offset); } // 3. Inside Data Range: Find the two candles it falls between // Binary search for efficiency let left = 0; let right = candleData.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (candleData[mid].time === time) return timeScale.timeToCoordinate(time); // Should have been caught by nativeCoord if (candleData[mid].time < time) left = mid + 1; else right = mid - 1; } // It falls between candleData[right] and candleData[left] const c1 = candleData[right]; const c2 = candleData[left]; if (!c1 || !c2) return null; // Should not happen given bounds check above const x1 = timeScale.timeToCoordinate(c1.time); const x2 = timeScale.timeToCoordinate(c2.time); if (x1 === null || x2 === null) { // If native coordinates fail (e.g. off-screen), fallback to logical interpolation const logical1 = right; const logical2 = left; const ratio = (time - c1.time) / (c2.time - c1.time); return timeScale.logicalToCoordinate(logical1 + ratio * (logical2 - logical1)); } // Linear interpolation between the two candle coordinates const ratio = (time - c1.time) / (c2.time - c1.time); return x1 + ratio * (x2 - x1); } hexToRgba(hex, opacity) { let r = 0, g = 0, b = 0; if (hex.length === 4) { r = parseInt(hex[1] + hex[1], 16); g = parseInt(hex[2] + hex[2], 16); b = parseInt(hex[3] + hex[3], 16); } else if (hex.length === 7) { r = parseInt(hex.slice(1, 3), 16); g = parseInt(hex.slice(3, 5), 16); b = parseInt(hex.slice(5, 7), 16); } return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; } render(target) { target.useMediaCoordinateSpace((scope) => { const ctx = scope.context; const chart = this.chart; const series = this.series; const allDrawings = [...this.drawings]; if (this.currentDrawing) allDrawings.push(this.currentDrawing); allDrawings.forEach(d => { const isSelected = d === this.selectedDrawing; ctx.save(); // Base Style const color = d.opacity !== undefined ? this.hexToRgba(d.color, d.opacity) : d.color; ctx.strokeStyle = color; ctx.lineWidth = d.width || (isSelected ? 3 : 2); if (d.style === 1) ctx.setLineDash([10, 5]); // Dashed else if (d.style === 2) ctx.setLineDash([2, 2]); // Dotted else ctx.setLineDash([]); // Solid if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; } if (d.type === 'trend_line' || d.type === 'ray') { const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); const x2 = this.timeToX(d.p2.time); const y2 = series.priceToCoordinate(d.p2.price); if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { ctx.beginPath(); ctx.moveTo(x1, y1); if (d.type === 'ray') { const angle = Math.atan2(y2 - y1, x2 - x1); ctx.lineTo(x1 + Math.cos(angle) * 10000, y1 + Math.sin(angle) * 10000); } else { ctx.lineTo(x2, y2); } ctx.stroke(); // Render Text if present if (d.text) { ctx.save(); ctx.setLineDash([]); // Text should not be dashed const fontSize = d.fontSize || 14; const fontStyle = (d.bold ? 'bold ' : '') + (d.italic ? 'italic ' : ''); ctx.font = `${fontStyle}${fontSize}px Inter`; ctx.fillStyle = d.textColor || color; ctx.textAlign = d.alignHorz || 'left'; ctx.textBaseline = d.alignVert === 'middle' ? 'middle' : (d.alignVert === 'bottom' ? 'top' : 'bottom'); const tx = (x1 + x2) / 2; const ty = (y1 + y2) / 2; const offset = 10; const finalTy = d.alignVert === 'top' ? ty - offset : (d.alignVert === 'bottom' ? ty + offset : ty); ctx.fillText(d.text, tx, finalTy); ctx.restore(); } if (isSelected) { ctx.setLineDash([]); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x1, y1, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(x2, y2, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } } else if (d.type === 'rectangle') { const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); const x2 = this.timeToX(d.p2.time); const y2 = series.priceToCoordinate(d.p2.price); if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { ctx.fillStyle = d.color + '22'; ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)); ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)); if (isSelected) { ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x1, y1, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(x2, y2, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } } else if (d.type === 'horizontal_line') { const y = series.priceToCoordinate(d.price); if (y !== null && y >= 0 && y <= scope.mediaSize.height) { ctx.save(); ctx.strokeStyle = d.opacity !== undefined ? this.hexToRgba(d.color, d.opacity) : d.color; ctx.lineWidth = d.width || 2; if (d.style === 1) ctx.setLineDash([10, 5]); else if (d.style === 2) ctx.setLineDash([2, 2]); else ctx.setLineDash([]); if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; } ctx.beginPath(); ctx.moveTo(-1000, y); ctx.lineTo(scope.mediaSize.width + 1000, y); ctx.stroke(); if (isSelected) { ctx.setLineDash([]); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(0, y, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } // Store label position for hit detection (before rendering) let textX = scope.mediaSize.width / 2; let textY = y; let labelPos = null; if (d.text) { const fontSize = d.fontSize || 14; const fontStyle = (d.bold ? 'bold ' : '') + (d.italic ? 'italic ' : ''); const font = `${fontStyle}${fontSize}px Inter`; ctx.font = font; const metrics = ctx.measureText(d.text); const labelWidth = metrics.width + 16; const labelHeight = 24; const defaultLabelX = scope.mediaSize.width / 2 - labelWidth / 2; const offset = 10; const defaultLabelY = d.alignVert === 'top' ? y - 40 : (d.alignVert === 'bottom' ? y + 20 : y - 12); labelPos = { x: defaultLabelX + (d.labelOffset?.x || 0), y: defaultLabelY + (d.labelOffset?.y || 0), width: labelWidth, height: labelHeight }; textX = scope.mediaSize.width / 2; textY = d.alignVert === 'top' ? y - offset : (d.alignVert === 'bottom' ? y + offset : y); } // Render Text if present if (d.text) { ctx.save(); ctx.setLineDash([]); ctx.font = font; ctx.fillStyle = d.textColor || d.color; ctx.textAlign = d.alignHorz || 'center'; ctx.textBaseline = d.alignVert === 'middle' ? 'middle' : (d.alignVert === 'bottom' ? 'top' : 'bottom'); ctx.fillText(d.text, textX, textY); ctx.restore(); } // Store label position for hit detection if (labelPos) { d.labelPos = labelPos; } ctx.restore(); } } else if (d.type === 'vertical_line') { const x = this.timeToX(d.time); if (x !== null && x >= 0 && x <= scope.mediaSize.width) { ctx.save(); ctx.strokeStyle = d.opacity !== undefined ? this.hexToRgba(d.color, d.opacity) : d.color; ctx.lineWidth = d.width || 2; if (d.style === 1) ctx.setLineDash([10, 5]); else if (d.style === 2) ctx.setLineDash([2, 2]); else ctx.setLineDash([]); if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; } ctx.beginPath(); ctx.moveTo(x, -1000); ctx.lineTo(x, scope.mediaSize.height + 1000); ctx.stroke(); if (isSelected) { ctx.setLineDash([]); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x, 0, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } // Store label position for hit detection (before rendering) let textX = x; let textY = 0; let labelPos = null; if (d.text) { const fontSize = d.fontSize || 14; const fontStyle = (d.bold ? 'bold ' : '') + (d.italic ? 'italic ' : ''); const font = `${fontStyle}${fontSize}px Inter`; ctx.font = font; const metrics = ctx.measureText(d.text); const labelWidth = metrics.width + 16; const labelHeight = 24; const offset = 10; const defaultLabelY = d.alignVert === 'top' ? -50 : (d.alignVert === 'bottom' ? 20 : -12); labelPos = { x: x - labelWidth / 2 + (d.labelOffset?.x || 0), y: defaultLabelY + (d.labelOffset?.y || 0), width: labelWidth, height: labelHeight }; textY = d.alignVert === 'top' ? -offset : (d.alignVert === 'bottom' ? offset : 0); } // Render Text if present if (d.text) { ctx.save(); ctx.setLineDash([]); ctx.font = font; ctx.fillStyle = d.textColor || d.color; ctx.textAlign = d.alignHorz || 'center'; ctx.textBaseline = d.alignVert === 'middle' ? 'middle' : (d.alignVert === 'bottom' ? 'top' : 'bottom'); ctx.fillText(d.text, textX, textY); ctx.restore(); } // Store label position for hit detection if (labelPos) { d.labelPos = labelPos; } ctx.restore(); } } else if (d.type === 'fib_retracement') { const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); const x2 = this.timeToX(d.p2.time); const y2 = series.priceToCoordinate(d.p2.price); if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]; const range = d.p2.price - d.p1.price; ctx.setLineDash([5, 5]); ctx.lineWidth = 1; levels.forEach(level => { const price = d.p1.price + (range * level); const y = series.priceToCoordinate(price); if (y !== null) { ctx.beginPath(); ctx.moveTo(Math.min(x1, x2), y); ctx.lineTo(Math.max(x1, x2), y); ctx.stroke(); ctx.fillStyle = d.color; ctx.font = '10px Inter'; ctx.fillText(`${(level * 100).toFixed(1)}% (${price.toFixed(2)})`, Math.max(x1, x2) + 5, y + 3); } }); ctx.setLineDash([]); ctx.strokeStyle = d.color + '44'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); if (isSelected) { ctx.fillStyle = '#ffffff'; ctx.strokeStyle = d.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x1, y1, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(x2, y2, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } } else if (d.type === 'arrow') { const x = this.timeToX(d.time); const y = series.priceToCoordinate(d.price); if (x !== null && y !== null) { ctx.fillStyle = d.color; ctx.beginPath(); const size = isSelected ? 15 : 10; if (d.direction === 'up') { ctx.moveTo(x, y); ctx.lineTo(x - size/2, y + size); ctx.lineTo(x + size/2, y + size); } else { ctx.moveTo(x, y); ctx.lineTo(x - size/2, y - size); ctx.lineTo(x + size/2, y - size); } ctx.fill(); if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1; ctx.stroke(); } } } else if (d.type === 'text') { const x = this.timeToX(d.time); const y = series.priceToCoordinate(d.price); if (x !== null && y !== null) { ctx.fillStyle = d.color; ctx.font = isSelected ? 'bold 14px Inter' : '12px Inter'; ctx.fillText(d.text, x, y); if (isSelected) { const metrics = ctx.measureText(d.text); ctx.strokeStyle = d.color; ctx.strokeRect(x - 2, y - 12, metrics.width + 4, 16); } } } else if (d.type === 'measure') { const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); const x2 = this.timeToX(d.p2.time); const y2 = series.priceToCoordinate(d.p2.price); if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { const priceDiff = d.p2.price - d.p1.price; const percentChange = (priceDiff / d.p1.price) * 100; const isUp = priceDiff >= 0; const color = isUp ? '#26a69a' : '#ef5350'; // 1. Draw Measurement Area ctx.fillStyle = color + '33'; ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)); // 2. Draw Crosshairs & Arrows ctx.strokeStyle = color; ctx.lineWidth = 1; // Horizontal line ctx.beginPath(); ctx.moveTo(Math.min(x1, x2), (y1 + y2) / 2); ctx.lineTo(Math.max(x1, x2), (y1 + y2) / 2); ctx.stroke(); // Vertical line ctx.beginPath(); ctx.moveTo((x1 + x2) / 2, Math.min(y1, y2)); ctx.lineTo((x1 + x2) / 2, Math.max(y1, y2)); ctx.stroke(); // Draw Arrows at ends of crosshairs const drawArrow = (x, y, angle) => { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(-6, -4); ctx.lineTo(0, 0); ctx.lineTo(-6, 4); ctx.stroke(); ctx.restore(); }; // Time arrow (horizontal) if (x2 > x1) drawArrow(x2, (y1 + y2) / 2, 0); else drawArrow(x2, (y1 + y2) / 2, Math.PI); // Price arrow (vertical) if (y2 < y1) drawArrow((x1 + x2) / 2, y2, -Math.PI/2); else drawArrow((x1 + x2) / 2, y2, Math.PI/2); // 3. Data Calculation // Bar count const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || []; const startIdx = candleData.findIndex(c => c.time === this.snapToNearestTime(d.p1.time)); const endIdx = candleData.findIndex(c => c.time === this.snapToNearestTime(d.p2.time)); let barCount = 0; let totalVol = 0; if (startIdx !== -1 && endIdx !== -1) { barCount = endIdx - startIdx; const minIdx = Math.min(startIdx, endIdx); const maxIdx = Math.max(startIdx, endIdx); for (let i = minIdx; i <= maxIdx; i++) { totalVol += candleData[i].volume || 0; } } else { // Rough estimate if not in current data const intervalSec = this.getIntervalSeconds(); barCount = Math.round((d.p2.time - d.p1.time) / intervalSec); } // Time duration const diffSec = Math.abs(d.p2.time - d.p1.time); let timeStr = ""; if (diffSec >= 86400) { const days = Math.floor(diffSec / 86400); const hours = Math.floor((diffSec % 86400) / 3600); const mins = Math.floor((diffSec % 3600) / 60); timeStr = `${days}d${hours > 0 ? ` ${hours}h` : ''}${mins > 0 ? ` ${mins}m` : ''}`; } else { const hours = Math.floor(diffSec / 3600); const mins = Math.floor((diffSec % 3600) / 60); timeStr = `${hours}h${mins > 0 ? ` ${mins}m` : ''}`; } const volStr = totalVol > 1000 ? `${(totalVol/1000).toFixed(3)}K` : totalVol.toFixed(0); // 4. Draw Label Box const labelLines = [ `${priceDiff.toFixed(2)} (${percentChange.toFixed(2)}%) , ${Math.round(priceDiff * 100)}`, `${barCount} bars, ${timeStr}`, `Vol ${volStr}` ]; ctx.font = '500 12px Inter'; const labelWidth = Math.max(...labelLines.map(l => ctx.measureText(l).width)) + 24; const labelHeight = 65; const midX = (x1 + x2) / 2; const topY = Math.min(y1, y2); const bottomY = Math.max(y1, y2); const defaultLabelX = midX - labelWidth / 2; const defaultLabelY = isUp ? topY - labelHeight - 10 : bottomY + 10; const labelX = defaultLabelX + (d.labelOffset?.x || 0); const labelY = defaultLabelY + (d.labelOffset?.y || 0); // Store label position for hit detection d.labelPos = { x: labelX, y: labelY, width: labelWidth, height: labelHeight }; // Solid Box ctx.fillStyle = color; this.roundRect(ctx, labelX, labelY, labelWidth, labelHeight, 4); ctx.fill(); // Text ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; labelLines.forEach((line, i) => { ctx.fillText(line, labelX + labelWidth/2, labelY + 20 + (i * 18)); }); if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x1, y1, 5, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(x2, y2, 5, 0, Math.PI * 2); ctx.stroke(); } } } ctx.restore(); }); }); } roundRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } initSettingsPanel() { const panel = document.getElementById('trendlineSettingsPanel'); const header = document.getElementById('tlPanelHeader'); this.activeTLTab = 'style'; // Dragging Logic let isDragging = false; let startX, startY, initialX, initialY; header.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); initialX = rect.left; initialY = rect.top; panel.classList.remove('left-1/2', '-translate-x-1/2', 'top-14'); panel.style.left = initialX + 'px'; panel.style.top = initialY + 'px'; panel.style.margin = '0'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; panel.style.left = (initialX + dx) + 'px'; panel.style.top = (initialY + dy) + 'px'; }); window.addEventListener('mouseup', () => { isDragging = false; }); // Global functions for HTML event handlers window.toggleTLSettings = (show) => this.toggleSettingsPanel(show); window.switchTLTab = (tab) => { this.activeTLTab = tab; const isLineWithText = this.selectedDrawing && ['trend_line', 'ray', 'rectangle', 'horizontal_line', 'vertical_line'].includes(this.selectedDrawing.type); document.getElementById('tlTabStyle').className = tab === 'style' ? 'flex-1 py-2 text-center text-blue-500 border-b-2 border-blue-500 font-medium' : 'flex-1 py-2 text-center text-gray-400 hover:text-white'; const textTab = document.getElementById('tlTabText'); if (isLineWithText) { textTab.className = tab === 'text' ? 'flex-1 py-2 text-center text-blue-500 border-b-2 border-blue-500 font-medium' : 'flex-1 py-2 text-center text-gray-400 hover:text-white'; textTab.style.display = 'block'; document.getElementById('tlContentText').className = tab === 'text' ? 'block' : 'hidden'; } else { textTab.style.display = 'none'; document.getElementById('tlContentText').className = 'hidden'; } document.getElementById('tlContentStyle').className = tab === 'style' ? 'block' : 'hidden'; const textPicker = document.getElementById('tlTextColorPicker'); if (textPicker) textPicker.classList.add('hidden'); this.updateSettingsPanelUI(); }; window.setTLThickness = (width) => this.applySettings('width', width); window.setTLStyle = (style) => this.applySettings('style', style); window.toggleTLBold = () => { const current = this.selectedDrawing && ['trend_line', 'ray', 'rectangle', 'horizontal_line', 'vertical_line'].includes(this.selectedDrawing.type) ? this.selectedDrawing.bold : this.defaults.trend_line.bold; this.applySettings('bold', !current); }; window.toggleTLItalic = () => { const current = this.selectedDrawing && ['trend_line', 'ray', 'rectangle', 'horizontal_line', 'vertical_line'].includes(this.selectedDrawing.type) ? this.selectedDrawing.italic : this.defaults.trend_line.italic; this.applySettings('italic', !current); }; // Initialize Color Grids const colors = [ '#ffffff', '#e0e0e0', '#bdbdbd', '#9e9e9e', '#757575', '#616161', '#424242', '#212121', '#000000', '#ef5350', '#ff9800', '#ffeb3b', '#66bb6a', '#009688', '#00bcd4', '#2962ff', '#673ab7', '#9c27b0', '#e91e63', '#ffcdd2', '#ffe0b2', '#fff9c4', '#c8e6c9', '#b2dfdb', '#b2ebf2', '#bbdefb', '#d1c4e9', '#e1bee7', '#f8bbd0', '#ef9a9a', '#ffcc80', '#fff59d', '#a5d6a7', '#80cbc4', '#80deea', '#90caf9', '#b39ddb', '#ce93d8', '#f48fb1', '#e57373', '#ffb74d', '#fff176', '#81c784', '#4db6ac', '#4dd0e1', '#64b5f6', '#9575cd', '#ba68c8', '#f06292', '#f44336', '#fb8c00', '#fdd835', '#4caf50', '#00897b', '#00acc1', '#1e88e5', '#5e35b1', '#8e24aa', '#d81b60', '#c62828', '#ef6c00', '#f9a825', '#2e7d32', '#00695c', '#00838f', '#1565c0', '#4527a0', '#6a1b9a', '#ad1457', '#b71c1c', '#e65100', '#f57f17', '#1b5e20', '#004d40', '#006064', '#0d47a1', '#311b92', '#4a148c', '#880e4f' ]; const styleGrid = document.getElementById('tlColorGrid'); const textGrid = document.getElementById('tlTextColorGrid'); const populateGrid = (gridEl, key, closePopupEl = null) => { if (!gridEl) return; gridEl.innerHTML = ''; colors.forEach(color => { const btn = document.createElement('div'); btn.className = 'color-swatch'; btn.style.backgroundColor = color; btn.onclick = (e) => { e.stopPropagation(); this.applySettings(key, color); if (closePopupEl) closePopupEl.classList.add('hidden'); }; gridEl.appendChild(btn); }); }; populateGrid(styleGrid, 'color'); populateGrid(textGrid, 'textColor', document.getElementById('tlTextColorPicker')); const textColorBtn = document.getElementById('tlTextColorBtn'); const textPicker = document.getElementById('tlTextColorPicker'); if (textColorBtn && textPicker) { textColorBtn.onclick = (e) => { e.stopPropagation(); textPicker.classList.toggle('hidden'); }; } // Bind Inputs document.getElementById('tlOpacityInput').addEventListener('input', (e) => { document.getElementById('tlOpacityValue').textContent = e.target.value + '%'; this.applySettings('opacity', parseInt(e.target.value)); }); document.getElementById('tlFontSize').addEventListener('change', (e) => { this.applySettings('fontSize', parseInt(e.target.value)); }); document.getElementById('tlTextInput').addEventListener('input', (e) => { this.applySettings('text', e.target.value); }); document.getElementById('tlAlignVert').addEventListener('change', (e) => { this.applySettings('alignVert', e.target.value); }); document.getElementById('tlAlignHorz').addEventListener('change', (e) => { this.applySettings('alignHorz', e.target.value); }); // Close on click outside document.addEventListener('mousedown', (e) => { const panel = document.getElementById('trendlineSettingsPanel'); const toolbar = document.getElementById('drawingToolbar'); const textPicker = document.getElementById('tlTextColorPicker'); if (textPicker && !textPicker.classList.contains('hidden') && !textPicker.contains(e.target) && e.target !== document.getElementById('tlTextColorBtn')) { textPicker.classList.add('hidden'); } if (panel && !panel.classList.contains('hidden')) { if (!panel.contains(e.target) && !toolbar.contains(e.target)) { panel.classList.add('hidden'); } } }); } toggleSettingsPanel(show = true) { const panel = document.getElementById('trendlineSettingsPanel'); if (!panel) return; if (show) { panel.classList.remove('hidden'); this.updateSettingsPanelUI(); } else { panel.classList.add('hidden'); } } updateSettingsPanelUI() { const settings = this.selectedDrawing || this.defaults[this.selectedDrawing ? this.selectedDrawing.type : 'trend_line']; document.querySelectorAll('#tlColorGrid .color-swatch').forEach(el => { if (el.style.backgroundColor === settings.color) el.classList.add('active'); else el.classList.remove('active'); }); document.getElementById('tlOpacityInput').value = settings.opacity !== undefined ? settings.opacity : 100; document.getElementById('tlOpacityValue').textContent = (settings.opacity !== undefined ? settings.opacity : 100) + '%'; document.querySelectorAll('.tl-thickness-btn').forEach(btn => { btn.setAttribute('data-active', parseInt(btn.dataset.thickness) === settings.width); }); document.querySelectorAll('.tl-style-btn').forEach(btn => { btn.setAttribute('data-active', parseInt(btn.dataset.style) === settings.style); }); const isLineWithText = this.selectedDrawing && ['trend_line', 'ray', 'rectangle'].includes(this.selectedDrawing.type); const textColorBtn = document.getElementById('tlTextColorBtn'); const textPicker = document.getElementById('tlTextColorPicker'); const fontInput = document.getElementById('tlFontSize'); const textInput = document.getElementById('tlTextInput'); const alignVert = document.getElementById('tlAlignVert'); const alignHorz = document.getElementById('tlAlignHorz'); const boldBtn = document.getElementById('tlBoldBtn'); const italicBtn = document.getElementById('tlItalicBtn'); if (isLineWithText) { if (textColorBtn && textPicker) { textColorBtn.style.backgroundColor = settings.textColor || settings.color; textPicker.classList.remove('hidden'); } fontInput.value = settings.fontSize || 14; textInput.value = settings.text || ''; boldBtn.setAttribute('data-active', !!settings.bold); italicBtn.setAttribute('data-active', !!settings.italic); alignVert.value = settings.alignVert || 'top'; alignHorz.value = settings.alignHorz || 'left'; } else { if (textPicker) textPicker.classList.add('hidden'); } } applySettings(key, value) { if (this.selectedDrawing) { if (key === 'bold' || key === 'italic' || key === 'fontSize' || key === 'text' || key === 'textColor' || key === 'alignVert' || key === 'alignHorz') { const isLineWithText = ['trend_line', 'ray', 'rectangle'].includes(this.selectedDrawing.type); if (!isLineWithText) return; this.selectedDrawing[key] = value; } else { this.selectedDrawing[key] = value; } this.update(); } else { const drawingType = this.selectedDrawing ? this.selectedDrawing.type : 'trend_line'; if (key === 'bold' || key === 'italic' || key === 'fontSize' || key === 'text' || key === 'textColor' || key === 'alignVert' || key === 'alignHorz') { if (drawingType === 'trend_line' || drawingType === 'ray' || drawingType === 'rectangle') { this.defaults.trend_line[key] = value; } } else { if (drawingType === 'trend_line' || drawingType === 'ray' || drawingType === 'rectangle') { this.defaults.trend_line[key] = value; } else if (drawingType === 'horizontal_line') { this.defaults.horizontal_line[key] = value; } else if (drawingType === 'vertical_line') { this.defaults.vertical_line[key] = value; } else { this.defaults.trend_line[key] = value; } } } this.updateSettingsPanelUI(); } }