feat: implement professional drawing tools with selection and editing support

This commit is contained in:
DiTus
2026-03-21 11:36:17 +01:00
parent a34f80f841
commit eda151bff5
3 changed files with 546 additions and 376 deletions

View File

@ -105,44 +105,60 @@
<section class="relative w-full bg-[#0d1421] h-[60vh] md:h-[70vh]" data-purpose="chart-container" id="chartWrapper">
<div id="chart" class="w-full h-full"></div>
<!-- Overlay Controls -->
<div class="absolute bottom-4 right-4 flex gap-2 z-10 opacity-0 hover:opacity-100 transition-opacity" id="priceScaleControls">
<!-- Drawing Tools Button -->
<button class="w-8 h-8 bg-[#1e222d] border border-[#2d3a4f] text-gray-300 flex items-center justify-center rounded hover:bg-[#2d3a4f] transition-colors shadow-lg" id="btnDrawingTools" title="Drawing Tools">
<span class="material-symbols-outlined text-sm">draw</span>
</button>
<!-- Drawing Layer Overlay -->
<canvas id="drawingLayer" class="absolute inset-0 pointer-events-none z-20 w-full h-full"></canvas>
<!-- Drawing Tools Popup -->
<div class="hidden absolute bottom-10 right-0 bg-[#1a2333] border border-[#2d3a4f] rounded-lg py-2 z-50 w-64 shadow-xl text-sm" id="drawingToolsPopup">
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('trend_line')">
<span class="material-symbols-outlined text-sm">call_split</span> Trend Line
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('horizontal_line')">
<span class="material-symbols-outlined text-sm">swap_horizontal_circle</span> Horizontal Line
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('vertical_line')">
<span class="material-symbols-outlined text-sm">swap_vert</span> Vertical Line
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('text')">
<span class="material-symbols-outlined text-sm">text_fields</span> Text Label
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('arrow_up')">
<span class="material-symbols-outlined text-sm">arrow_upward</span> Arrow Up
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('arrow_down')">
<span class="material-symbols-outlined text-sm">arrow_downward</span> Arrow Down
</div>
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.activateDrawingTool('clear')">
<span class="material-symbols-outlined text-sm">clear_all</span> Clear All
</div>
</div>
<!-- Vertical Drawing Toolbar (Left) -->
<div class="absolute left-2 top-1/2 -translate-y-1/2 flex flex-col gap-1 z-30 bg-[#1a2333]/80 backdrop-blur border border-[#2d3a4f] p-1 rounded-md shadow-xl" id="drawingToolbar">
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('cursor')" title="Cursor">
<span class="material-symbols-outlined text-sm">near_me</span>
</button>
<div class="w-full h-[1px] bg-[#2d3a4f] my-0.5"></div>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('trend_line')" title="Trend Line">
<span class="material-symbols-outlined text-sm">call_split</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('ray')" title="Ray">
<span class="material-symbols-outlined text-sm">trending_flat</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('horizontal_line')" title="Horizontal Line">
<span class="material-symbols-outlined text-sm">swap_horizontal_circle</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('vertical_line')" title="Vertical Line">
<span class="material-symbols-outlined text-sm">swap_vert</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('fib_retracement')" title="Fibonacci Retracement">
<span class="material-symbols-outlined text-sm">reorder</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('rectangle')" title="Rectangle">
<span class="material-symbols-outlined text-sm">crop_square</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('text')" title="Text Label">
<span class="material-symbols-outlined text-sm">text_fields</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('arrow_up')" title="Arrow Up">
<span class="material-symbols-outlined text-sm">arrow_upward</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('arrow_down')" title="Arrow Down">
<span class="material-symbols-outlined text-sm">arrow_downward</span>
</button>
<button class="w-8 h-8 text-gray-400 hover:text-white hover:bg-[#2d3a4f] rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('measure')" title="Measure">
<span class="material-symbols-outlined text-sm">straighten</span>
</button>
<div class="w-full h-[1px] bg-[#2d3a4f] my-0.5"></div>
<button class="w-8 h-8 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded flex items-center justify-center transition-colors" onclick="window.activateDrawingTool('clear')" title="Clear All">
<span class="material-symbols-outlined text-sm">delete</span>
</button>
</div>
<!-- Overlay Controls -->
<div class="absolute bottom-4 right-4 flex gap-2 z-10 opacity-0 hover:opacity-100 transition-opacity" id="priceScaleControls">
<button class="w-8 h-8 bg-[#1e222d] border border-[#2d3a4f] text-gray-300 flex items-center justify-center rounded hover:bg-[#2d3a4f] transition-colors shadow-lg" id="btnSettings" title="Settings">
<span class="material-symbols-outlined text-sm">settings</span>
</button>
<!-- Settings Popup -->
<div class="hidden absolute bottom-10 right-0 bg-[#1a2333] border border-[#2d3a4f] rounded-lg py-2 z-50 w-64 shadow-xl text-sm" id="settingsPopup">
<!-- Reset Scale -->
<div class="px-4 py-2 hover:bg-[#252f3f] cursor-pointer flex items-center gap-3" onclick="window.dashboard.chart.timeScale().fitContent()">
<span class="material-symbols-outlined text-sm">refresh</span> Reset price scale

View File

@ -149,8 +149,10 @@ function throttle(func, limit) {
}
}
import { DrawingManager } from './drawing-tools.js';
export class TradingDashboard {
constructor() {
constructor() {
this.chart = null;
this.candleSeries = null;
// Load settings from local storage or defaults
@ -169,14 +171,13 @@ constructor() {
this.avgPriceSeries = null;
this.dailyMAData = new Map(); // timestamp -> { ma44, ma125, price }
this.currentMouseTime = null;
this.drawingItems = []; // Store drawing items for management
this.drawingManager = null;
// Throttled versions of heavy functions
this.throttledOnVisibleRangeChange = throttle(this.onVisibleRangeChange.bind(this), 150);
this.init();
}
async loadDailyMAData() {
try {
// Use 1d interval for this calculation
@ -454,6 +455,14 @@ constructor() {
this.initPriceScaleControls();
this.initNavigationControls();
// Initialize Drawing Manager
const drawingLayer = document.getElementById('drawingLayer');
if (drawingLayer) {
this.drawingManager = new DrawingManager(this.chart, this.candleSeries, drawingLayer, chartContainer);
window.activateDrawingTool = (tool) => this.drawingManager.setTool(tool);
}
// Setup price format selector change handler
document.addEventListener("DOMContentLoaded", () => {
const priceSelect = document.getElementById("priceFormatSelect");
@ -523,9 +532,6 @@ constructor() {
}
}
// Drawing Tools
this.initDrawingTools();
// Initialize state from storage
this.scaleState = {
autoScale: localStorage.getItem('winterfail_scale_auto') !== 'false',
@ -583,341 +589,6 @@ constructor() {
});
}
initDrawingTools() {
const btnDrawingTools = document.getElementById('btnDrawingTools');
const drawingToolsPopup = document.getElementById('drawingToolsPopup');
if (btnDrawingTools && drawingToolsPopup) {
btnDrawingTools.addEventListener('click', (e) => {
e.stopPropagation();
drawingToolsPopup.classList.toggle('hidden');
});
document.addEventListener('click', closeDrawingToolsPopup);
document.addEventListener('touchstart', closeDrawingToolsPopup, { passive: true });
function closeDrawingToolsPopup(e) {
const isInside = drawingToolsPopup.contains(e.target) || e.target === btnDrawingTools;
const isDrawingButton = e.target.closest('#btnDrawingTools');
if (!isInside && !isDrawingButton) {
drawingToolsPopup.classList.add('hidden');
}
}
}
let isDrawing = false;
let drawMode = null;
let startPoint = null;
let drawLine = null;
let drawArrow = null;
let drawText = null;
window.activateDrawingTool = (tool) => {
if (tool === 'clear') {
this.clearDrawingTools();
return;
}
drawMode = tool;
isDrawing = true;
const chart = this.chart;
const container = chart.container();
const onMouseDown = (e) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (drawMode === 'trend_line' || drawMode === 'horizontal_line' || drawMode === 'vertical_line') {
this.createLine(x, y);
} else if (drawMode === 'arrow_up' || drawMode === 'arrow_down') {
this.createArrow(drawMode === 'arrow_up' ? 'arrowUp' : 'arrowDown', x, y);
} else if (drawMode === 'text') {
this.createText(x, y);
}
};
const onTouchStart = (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = container.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (drawMode === 'trend_line' || drawMode === 'horizontal_line' || drawMode === 'vertical_line') {
this.createLine(x, y);
} else if (drawMode === 'arrow_up' || drawMode === 'arrow_down') {
this.createArrow(drawMode === 'arrow_up' ? 'arrowUp' : 'arrowDown', x, y);
} else if (drawMode === 'text') {
this.createText(x, y);
}
};
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('touchstart', onTouchStart);
const closeDrawing = () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('click', closeDrawing);
document.removeEventListener('touchstart', closeDrawing);
};
document.addEventListener('click', closeDrawing);
document.addEventListener('touchstart', closeDrawing);
drawingToolsPopup.classList.add('hidden');
};
}
createLine(x, y) {
const chart = this.chart;
const candleSeries = this.candleSeries;
let lineData = {
time: [],
value: []
};
const line = chart.addLineSeries({
color: '#2962ff',
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Solid,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
baseIndex: 0,
});
let points = [];
const container = chart.container();
let startTime = null;
let startPrice = null;
const onMouseDown = (e) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const clientX = e.clientX;
const clientY = e.clientY;
const timeCoord = chart.timeScale().coordinateToTime(x);
const price = chart.priceScale().coordinateToPrice(y);
if (startTime === null) {
startTime = timeCoord;
startPrice = price;
line.setData([{
time: startTime,
value: startPrice
}]);
} else {
line.setData([
{ time: startTime, value: startPrice },
{ time: timeCoord, value: price }
]);
startTime = null;
startPrice = null;
}
};
const onTouchStart = (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = container.getBoundingClientRect();
const clientX = touch.clientX;
const clientY = touch.clientY;
const timeCoord = chart.timeScale().coordinateToTime(x);
const price = chart.priceScale().coordinateToPrice(y);
if (startTime === null) {
startTime = timeCoord;
startPrice = price;
line.setData([{
time: startTime,
value: startPrice
}]);
} else {
line.setData([
{ time: startTime, value: startPrice },
{ time: timeCoord, value: price }
]);
startTime = null;
startPrice = null;
}
};
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('touchstart', onTouchStart);
const cleanup = () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('touchstart', onTouchStart);
};
document.addEventListener('click', cleanup);
document.addEventListener('touchstart', cleanup);
}
createArrow(arrowType, x, y) {
const chart = this.chart;
const container = chart.container();
const markerPrimitive = new SeriesMarkersPrimitive();
this.candleSeries.attachPrimitive(markerPrimitive);
const rect = container.getBoundingClientRect();
const time = chart.timeScale().coordinateToTime(x);
const price = chart.priceScale().coordinateToPrice(y);
const marker = {
time: time,
position: arrowType === 'arrowUp' ? 'belowBar' : 'aboveBar',
color: arrowType === 'arrowUp' ? '#26a69a' : '#ef5350',
shape: arrowType === 'arrowUp' ? 'arrowUp' : 'arrowDown',
text: ''
};
markerPrimitive.setMarkers([marker]);
let isDragging = false;
let startX = 0;
let startY = 0;
let startTime = time;
let startPrice = price;
const onMouseDown = (e) => {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startY = e.clientY;
};
const onMouseMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const rect = container.getBoundingClientRect();
const timeCoord = chart.timeScale().coordinateToTime(x + dx);
const newPrice = chart.priceScale().coordinateToPrice(y + dy);
const newMarker = {
time: timeCoord,
position: arrowType === 'arrowUp' ? 'belowBar' : 'aboveBar',
color: arrowType === 'arrowUp' ? '#26a69a' : '#ef5350',
shape: arrowType === 'arrowUp' ? 'arrowUp' : 'arrowDown',
text: ''
};
markerPrimitive.setMarkers([newMarker]);
};
const onMouseUp = () => {
isDragging = false;
};
container.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
const cleanup = () => {
container.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('click', cleanup);
document.removeEventListener('touchstart', cleanup);
};
document.addEventListener('click', cleanup);
document.addEventListener('touchstart', cleanup);
}
createText(x, y) {
const chart = this.chart;
const container = chart.container();
const text = prompt('Enter text label:', '');
if (!text) return;
const rect = container.getBoundingClientRect();
const time = chart.timeScale().coordinateToTime(x);
const price = chart.priceScale().coordinateToPrice(y);
const label = chart.addLabel({
text: text,
color: '#ffffff',
fontSize: 11,
fontFamily: 'Inter',
fontWeight: 'bold',
crosshairMarkerVisible: false,
time: time,
price: price
});
let isDragging = false;
let startX = 0;
let startY = 0;
let startTime = time;
let startPrice = price;
const onMouseDown = (e) => {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startY = e.clientY;
};
const onMouseMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newTime = chart.timeScale().coordinateToTime(x + dx);
const newPrice = chart.priceScale().coordinateToPrice(y + dy);
label.applyOptions({
time: newTime,
price: newPrice
});
};
const onMouseUp = () => {
isDragging = false;
};
container.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
const cleanup = () => {
container.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('click', cleanup);
document.removeEventListener('touchstart', cleanup);
};
document.addEventListener('click', cleanup);
document.addEventListener('touchstart', cleanup);
}
clearDrawingTools() {
const allSeries = this.chart.series();
allSeries.forEach(series => {
if (series.type !== 'Candlestick' && series.type !== 'Line' && series.type !== 'Area') {
this.chart.removeSeries(series);
}
});
}
initNavigationControls() {
const chartWrapper = document.getElementById('chartWrapper');
const navLeft = document.getElementById('navLeft');

483
js/ui/drawing-tools.js Normal file
View File

@ -0,0 +1,483 @@
/**
* Drawing Tools for Lightweight Charts
* Implements professional trading chart drawing primitives
*/
export class DrawingManager {
constructor(chart, series, canvas, container) {
this.chart = chart;
this.series = series;
this.canvas = canvas;
this.container = container;
this.ctx = canvas.getContext('2d');
this.drawings = [];
this.activeTool = null;
this.currentDrawing = null;
this.selectedDrawing = null;
this.dragMode = null;
this.isMouseDown = false;
// Dragging offsets
this.dragStartPos = null;
this.startP1 = null;
this.startP2 = null;
this.startPrice = null;
this.startTime = null;
this.init();
}
init() {
const container = this.container;
container.addEventListener('mousedown', (e) => this.handleMouseDown(e));
window.addEventListener('mousemove', (e) => this.handleMouseMove(e));
window.addEventListener('mouseup', (e) => this.handleMouseUp(e));
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)
})
}]
};
this.series.attachPrimitive(this.drawingPrimitive);
}
updateChartInteractions() {
// Disable chart interactions if a tool is active, if we are currently drawing,
// or if we are dragging an existing object.
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) {
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();
}
getMousePos(e) {
const rect = this.container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = this.chart.timeScale().coordinateToTime(x);
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: '#2962ff'
};
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: '#2962ff'
};
this.update();
return;
}
}
if (!this.activeTool) {
const hit = this.findHit(pos.x, pos.y);
if (hit) {
this.selectedDrawing = hit.drawing;
this.dragMode = hit.part;
// Store initial state for offset movement
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(); // Freeze chart for dragging
this.update();
return;
} else {
this.selectedDrawing = null;
this.dragMode = null;
}
}
if (!this.activeTool || pos.time === null || pos.price === null) {
this.update();
return;
}
const color = '#2962ff';
const p1 = { time: pos.time, price: pos.price };
const p2 = { time: pos.time, price: pos.price };
if (this.activeTool === 'trend_line' || this.activeTool === 'ray' || this.activeTool === 'rectangle') {
this.currentDrawing = { type: this.activeTool, p1, p2, color };
} else if (this.activeTool === 'horizontal_line') {
this.drawings.push({ type: 'horizontal_line', price: pos.price, color });
this.setTool(null);
} else if (this.activeTool === 'vertical_line') {
this.drawings.push({ type: 'vertical_line', time: pos.time, color });
this.setTool(null);
} else if (this.activeTool === 'fib_retracement') {
this.currentDrawing = { type: 'fib_retracement', p1, p2, color: '#fb8c00' };
} else if (this.activeTool === 'measure') {
this.currentDrawing = { type: 'measure', p1, p2, color };
} 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);
if (!this.isMouseDown && !this.activeTool) {
const hit = this.findHit(pos.x, pos.y);
this.container.style.cursor = hit ? 'pointer' : '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;
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') {
const timeDiff = pos.time - this.dragStartPos.time;
const priceDiff = pos.price - this.dragStartPos.price;
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() {
// Special case for measure tool (Click-Move-Click)
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(); // Ensure interactions are restored (panning/zooming)
this.update();
}
findHit(x, y) {
const threshold = 10;
for (let i = this.drawings.length - 1; i >= 0; i--) {
const d = this.drawings[i];
if (d.p1 && d.p2) {
const x1 = this.chart.timeScale().timeToCoordinate(d.p1.time);
const y1 = this.series.priceToCoordinate(d.p1.price);
const x2 = this.chart.timeScale().timeToCoordinate(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') {
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 (Math.abs(y - dy) < threshold) return { drawing: d, part: 'price' };
} else if (d.time !== undefined && d.type === 'vertical_line') {
const dx = this.chart.timeScale().timeToCoordinate(d.time);
if (Math.abs(x - dx) < threshold) return { drawing: d, part: 'time' };
} else if (d.time !== undefined && d.price !== undefined) {
const dx = this.chart.timeScale().timeToCoordinate(d.time);
const dy = this.series.priceToCoordinate(d.price);
if (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();
}
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();
ctx.strokeStyle = d.color;
ctx.lineWidth = isSelected ? 3 : 2;
if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; }
if (d.type === 'trend_line' || d.type === 'ray') {
const x1 = chart.timeScale().timeToCoordinate(d.p1.time);
const y1 = series.priceToCoordinate(d.p1.price);
const x2 = chart.timeScale().timeToCoordinate(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();
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 === 'rectangle') {
const x1 = chart.timeScale().timeToCoordinate(d.p1.time);
const y1 = series.priceToCoordinate(d.p1.price);
const x2 = chart.timeScale().timeToCoordinate(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) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(scope.mediaSize.width, y); ctx.stroke();
}
} else if (d.type === 'vertical_line') {
const x = chart.timeScale().timeToCoordinate(d.time);
if (x !== null) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, scope.mediaSize.height); ctx.stroke();
}
} else if (d.type === 'fib_retracement') {
const x1 = chart.timeScale().timeToCoordinate(d.p1.time);
const y1 = series.priceToCoordinate(d.p1.price);
const x2 = chart.timeScale().timeToCoordinate(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 = chart.timeScale().timeToCoordinate(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 = chart.timeScale().timeToCoordinate(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 = chart.timeScale().timeToCoordinate(d.p1.time);
const y1 = series.priceToCoordinate(d.p1.price);
const x2 = chart.timeScale().timeToCoordinate(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 isPositive = priceDiff >= 0;
const measureColor = isPositive ? '#26a69a' : '#ef5350';
const timeSpanSeconds = Math.abs(d.p2.time - d.p1.time);
const days = (timeSpanSeconds / 86400).toFixed(1);
ctx.fillStyle = measureColor + '22';
ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
ctx.strokeStyle = measureColor;
ctx.lineWidth = isSelected ? 2 : 1;
ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const text = [`${priceDiff.toFixed(2)} (${percentChange.toFixed(2)}%)`, `${days} days`];
const padding = 5, lineHeight = 14, boxWidth = 100, boxHeight = text.length * lineHeight + padding * 2;
const boxX = x2 + 10, boxY = y2 - boxHeight / 2;
ctx.fillStyle = '#1a2333'; ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
ctx.strokeStyle = measureColor; ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.font = '11px Inter';
text.forEach((line, i) => { ctx.fillText(line, boxX + padding, boxY + padding + 10 + (i * lineHeight)); });
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();
}
}
}
ctx.restore();
});
});
}
}