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 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);