Fix drawing tools: allow future/whitespace drawing and improve TF scaling precision

This commit is contained in:
DiTus
2026-03-21 22:31:24 +01:00
parent ace4d4f49e
commit cb2cd53a8a

View File

@ -87,13 +87,16 @@ export class DrawingManager {
const isUp = d.p2.price >= d.p1.price; const isUp = d.p2.price >= d.p1.price;
const color = isUp ? '#26a69a' : '#ef5350'; const color = isUp ? '#26a69a' : '#ef5350';
[d.p1.price, d.p2.price].forEach(price => { [d.p1.price, d.p2.price].forEach(price => {
views.push({ const coord = this.series.priceToCoordinate(price);
coordinate: () => this.series.priceToCoordinate(price), if (coord !== null) {
text: () => price.toFixed(2), views.push({
textColor: () => '#ffffff', coordinate: () => coord,
backColor: () => color, text: () => price.toFixed(2),
visible: () => true, textColor: () => '#ffffff',
}); backColor: () => color,
visible: () => true,
});
}
}); });
}); });
return views; return views;
@ -109,22 +112,25 @@ export class DrawingManager {
const isUp = d.p2.price >= d.p1.price; const isUp = d.p2.price >= d.p1.price;
const color = isUp ? '#26a69a' : '#ef5350'; const color = isUp ? '#26a69a' : '#ef5350';
[d.p1.time, d.p2.time].forEach(time => { [d.p1.time, d.p2.time].forEach(time => {
views.push({ const coord = this.timeToX(time);
coordinate: () => this.chart.timeScale().timeToCoordinate(time), if (coord !== null) {
text: () => { views.push({
const date = new Date(time * 1000); coordinate: () => coord,
const day = date.getDate().toString().padStart(2, '0'); text: () => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const date = new Date(time * 1000);
const month = months[date.getMonth()]; const day = date.getDate().toString().padStart(2, '0');
const year = date.getFullYear().toString().slice(-2); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const hours = date.getHours().toString().padStart(2, '0'); const month = months[date.getMonth()];
const mins = date.getMinutes().toString().padStart(2, '0'); const year = date.getFullYear().toString().slice(-2);
return `${day} ${month} '${year} ${hours}:${mins}`; const hours = date.getHours().toString().padStart(2, '0');
}, const mins = date.getMinutes().toString().padStart(2, '0');
textColor: () => '#ffffff', return `${day} ${month} '${year} ${hours}:${mins}`;
backColor: () => color, },
visible: () => true, textColor: () => '#ffffff',
}); backColor: () => color,
visible: () => true,
});
}
}); });
}); });
return views; return views;
@ -178,11 +184,51 @@ export class DrawingManager {
this.update(); 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) { getMousePos(e) {
const rect = this.container.getBoundingClientRect(); const rect = this.container.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; 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); const price = this.series.coordinateToPrice(y);
return { x, y, time, price }; return { x, y, time, price };
} }
@ -412,9 +458,9 @@ export class DrawingManager {
} }
if (d.p1 && d.p2) { 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 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); const y2 = this.series.priceToCoordinate(d.p2.price);
if (x1 === null || y1 === null || x2 === null || y2 === null) continue; 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') { } else if (d.price !== undefined && d.type === 'horizontal_line') {
const dy = this.series.priceToCoordinate(d.price); 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') { } else if (d.time !== undefined && d.type === 'vertical_line') {
const dx = this.chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); const dx = this.timeToX(d.time);
if (Math.abs(x - dx) < threshold) return { drawing: d, part: 'time' }; if (dx !== null && Math.abs(x - dx) < threshold) return { drawing: d, part: 'time' };
} else if (d.time !== undefined && d.price !== undefined) { } 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); 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; return null;
@ -460,8 +506,25 @@ export class DrawingManager {
const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || []; const candleData = this.dashboard.allData.get(this.dashboard.currentInterval) || [];
if (candleData.length === 0) return time; 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 // 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 nearest = candleData[0].time;
let minDiff = Math.abs(time - nearest); let minDiff = Math.abs(time - nearest);
@ -481,6 +544,66 @@ export class DrawingManager {
return nearest; 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) { render(target) {
target.useMediaCoordinateSpace((scope) => { target.useMediaCoordinateSpace((scope) => {
const ctx = scope.context; const ctx = scope.context;
@ -497,9 +620,9 @@ export class DrawingManager {
if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; } if (isSelected) { ctx.shadowBlur = 5; ctx.shadowColor = d.color; }
if (d.type === 'trend_line' || d.type === 'ray') { 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 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); const y2 = series.priceToCoordinate(d.p2.price);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
ctx.beginPath(); ctx.beginPath();
@ -518,9 +641,9 @@ export class DrawingManager {
} }
} }
} else if (d.type === 'rectangle') { } 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 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); const y2 = series.priceToCoordinate(d.p2.price);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
ctx.fillStyle = d.color + '22'; ctx.fillStyle = d.color + '22';
@ -536,22 +659,22 @@ export class DrawingManager {
const y = series.priceToCoordinate(d.price); const y = series.priceToCoordinate(d.price);
if (y !== null) { if (y !== null) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, y); ctx.moveTo(-1000, y);
ctx.lineTo(scope.mediaSize.width, y); ctx.lineTo(scope.mediaSize.width + 1000, y);
ctx.stroke(); ctx.stroke();
} }
} else if (d.type === 'vertical_line') { } else if (d.type === 'vertical_line') {
const x = chart.timeScale().timeToCoordinate(this.snapToNearestTime(d.time)); const x = this.timeToX(d.time);
if (x !== null) { if (x !== null) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, 0); ctx.moveTo(x, -1000);
ctx.lineTo(x, scope.mediaSize.height); ctx.lineTo(x, scope.mediaSize.height + 1000);
ctx.stroke(); ctx.stroke();
} }
} else if (d.type === 'fib_retracement') { } 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 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); const y2 = series.priceToCoordinate(d.p2.price);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]; 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') { } 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); const y = series.priceToCoordinate(d.price);
if (x !== null && y !== null) { if (x !== null && y !== null) {
ctx.fillStyle = d.color; ctx.beginPath(); ctx.fillStyle = d.color; ctx.beginPath();
@ -593,7 +716,7 @@ export class DrawingManager {
if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1; ctx.stroke(); } if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1; ctx.stroke(); }
} }
} else if (d.type === 'text') { } 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); const y = series.priceToCoordinate(d.price);
if (x !== null && y !== null) { if (x !== null && y !== null) {
ctx.fillStyle = d.color; ctx.fillStyle = d.color;
@ -606,9 +729,9 @@ export class DrawingManager {
} }
} }
} else if (d.type === 'measure') { } 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 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); const y2 = series.priceToCoordinate(d.p2.price);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) { if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
@ -675,8 +798,8 @@ export class DrawingManager {
} }
} else { } else {
// Rough estimate if not in current data // Rough estimate if not in current data
const intervalSeconds = 60; // Default fallback const intervalSec = this.getIntervalSeconds();
barCount = Math.round((d.p2.time - d.p1.time) / intervalSeconds); barCount = Math.round((d.p2.time - d.p1.time) / intervalSec);
} }
// Time duration // Time duration
@ -708,7 +831,6 @@ export class DrawingManager {
// Natural position: Centered horizontally at midpoint, // Natural position: Centered horizontally at midpoint,
// vertically offset from the top/bottom edge. // vertically offset from the top/bottom edge.
// This makes the label follow the box edges correctly during resize.
const midX = (x1 + x2) / 2; const midX = (x1 + x2) / 2;
const topY = Math.min(y1, y2); const topY = Math.min(y1, y2);
const bottomY = Math.max(y1, y2); const bottomY = Math.max(y1, y2);