From cb2cd53a8aeaa2bb0e708246eae380f6afb62947 Mon Sep 17 00:00:00 2001 From: DiTus Date: Sat, 21 Mar 2026 22:31:24 +0100 Subject: [PATCH] Fix drawing tools: allow future/whitespace drawing and improve TF scaling precision --- js/ui/drawing-tools.js | 222 +++++++++++++++++++++++++++++++---------- 1 file changed, 172 insertions(+), 50 deletions(-) diff --git a/js/ui/drawing-tools.js b/js/ui/drawing-tools.js index d6eb959..4e7c9d0 100644 --- a/js/ui/drawing-tools.js +++ b/js/ui/drawing-tools.js @@ -87,13 +87,16 @@ export class DrawingManager { const isUp = d.p2.price >= d.p1.price; const color = isUp ? '#26a69a' : '#ef5350'; [d.p1.price, d.p2.price].forEach(price => { - views.push({ - coordinate: () => this.series.priceToCoordinate(price), - text: () => price.toFixed(2), - textColor: () => '#ffffff', - backColor: () => color, - visible: () => true, - }); + 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; @@ -109,22 +112,25 @@ export class DrawingManager { const isUp = d.p2.price >= d.p1.price; const color = isUp ? '#26a69a' : '#ef5350'; [d.p1.time, d.p2.time].forEach(time => { - views.push({ - coordinate: () => this.chart.timeScale().timeToCoordinate(time), - 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, - }); + 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; @@ -178,11 +184,51 @@ export class DrawingManager { 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 time = this.chart.timeScale().coordinateToTime(x); + + 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 }; } @@ -412,9 +458,9 @@ export class DrawingManager { } if (d.p1 && d.p2) { - const x1 = this.chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p1.time)); + const x1 = this.timeToX(d.p1.time); const y1 = this.series.priceToCoordinate(d.p1.price); - const x2 = this.chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p2.time)); + 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; @@ -430,14 +476,14 @@ export class DrawingManager { } } 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' }; + if (dy !== null && 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(this.snapToNearestTime(d.time)); - if (Math.abs(x - dx) < threshold) return { drawing: d, part: 'time' }; + const dx = this.timeToX(d.time); + if (dx !== null && Math.abs(x - dx) < threshold) return { drawing: d, part: 'time' }; } else if (d.time !== undefined && d.price !== undefined) { - const dx = this.chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); + const dx = this.timeToX(d.time); const dy = this.series.priceToCoordinate(d.price); - if (Math.hypot(x - dx, y - dy) < threshold) return { drawing: d, part: 'all' }; + if (dx !== null && dy !== null && Math.hypot(x - dx, y - dy) < threshold) return { drawing: d, part: 'all' }; } } return null; @@ -460,8 +506,25 @@ export class DrawingManager { 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, but for now a simple find is safest + // Since data is sorted, we can be efficient let nearest = candleData[0].time; let minDiff = Math.abs(time - nearest); @@ -481,6 +544,66 @@ export class DrawingManager { 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); + } + render(target) { target.useMediaCoordinateSpace((scope) => { const ctx = scope.context; @@ -497,9 +620,9 @@ export class DrawingManager { if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; } if (d.type === 'trend_line' || d.type === 'ray') { - const x1 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p1.time)); + const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); - const x2 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p2.time)); + 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(); @@ -518,9 +641,9 @@ export class DrawingManager { } } } else if (d.type === 'rectangle') { - const x1 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p1.time)); + const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); - const x2 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p2.time)); + 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'; @@ -536,22 +659,22 @@ export class DrawingManager { const y = series.priceToCoordinate(d.price); if (y !== null) { ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(scope.mediaSize.width, y); + ctx.moveTo(-1000, y); + ctx.lineTo(scope.mediaSize.width + 1000, y); ctx.stroke(); } } else if (d.type === 'vertical_line') { - const x = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); + const x = this.timeToX(d.time); if (x !== null) { ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, scope.mediaSize.height); + ctx.moveTo(x, -1000); + ctx.lineTo(x, scope.mediaSize.height + 1000); ctx.stroke(); } } else if (d.type === 'fib_retracement') { - const x1 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p1.time)); + const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); - const x2 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p2.time)); + 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]; @@ -575,7 +698,7 @@ export class DrawingManager { } } } else if (d.type === 'arrow') { - const x = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); + const x = this.timeToX(d.time); const y = series.priceToCoordinate(d.price); if (x !== null && y !== null) { ctx.fillStyle = d.color; ctx.beginPath(); @@ -593,7 +716,7 @@ export class DrawingManager { if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1; ctx.stroke(); } } } else if (d.type === 'text') { - const x = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); + const x = this.timeToX(d.time); const y = series.priceToCoordinate(d.price); if (x !== null && y !== null) { ctx.fillStyle = d.color; @@ -606,9 +729,9 @@ export class DrawingManager { } } } else if (d.type === 'measure') { - const x1 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p1.time)); + const x1 = this.timeToX(d.p1.time); const y1 = series.priceToCoordinate(d.p1.price); - const x2 = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.p2.time)); + const x2 = this.timeToX(d.p2.time); const y2 = series.priceToCoordinate(d.p2.price); if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { @@ -675,8 +798,8 @@ export class DrawingManager { } } else { // Rough estimate if not in current data - const intervalSeconds = 60; // Default fallback - barCount = Math.round((d.p2.time - d.p1.time) / intervalSeconds); + const intervalSec = this.getIntervalSeconds(); + barCount = Math.round((d.p2.time - d.p1.time) / intervalSec); } // Time duration @@ -708,7 +831,6 @@ export class DrawingManager { // Natural position: Centered horizontally at midpoint, // vertically offset from the top/bottom edge. - // This makes the label follow the box edges correctly during resize. const midX = (x1 + x2) / 2; const topY = Math.min(y1, y2); const bottomY = Math.max(y1, y2);