760 lines
26 KiB
TypeScript
760 lines
26 KiB
TypeScript
import { useRef, useEffect, useCallback, useState } from 'react';
|
|
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
|
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
|
|
|
|
interface CandlestickChartProps {
|
|
data: OHLCVBar[];
|
|
srLevels?: SRLevel[];
|
|
zones?: SRZone[];
|
|
tradeSetup?: TradeSetup;
|
|
currentPrice?: number;
|
|
}
|
|
|
|
/** A horizontal price marker to draw, with an optional band (zone). */
|
|
interface SRMarker {
|
|
price: number;
|
|
low: number;
|
|
high: number;
|
|
strength: number;
|
|
role: 'support' | 'resistance';
|
|
}
|
|
|
|
/**
|
|
* Pick only the nearest support below and nearest resistance above the current
|
|
* price — the two levels that actually matter for a trade right now. Keeps the
|
|
* chart readable; the full S/R list lives in the S/R tab.
|
|
*/
|
|
function nearestSRMarkers(
|
|
srLevels: SRLevel[],
|
|
zones: SRZone[],
|
|
price: number,
|
|
): SRMarker[] {
|
|
const raw: SRMarker[] = zones.length
|
|
? zones.map((z) => ({ price: z.midpoint, low: z.low, high: z.high, strength: z.strength, role: 'support' as const }))
|
|
: srLevels.map((l) => ({ price: l.price_level, low: l.price_level, high: l.price_level, strength: l.strength, role: 'support' as const }));
|
|
|
|
const below = raw.filter((m) => m.high <= price).sort((a, b) => (price - a.price) - (price - b.price));
|
|
const above = raw.filter((m) => m.low > price).sort((a, b) => (a.price - price) - (b.price - price));
|
|
|
|
const out: SRMarker[] = [];
|
|
if (below[0]) out.push({ ...below[0], role: 'support' });
|
|
if (above[0]) out.push({ ...above[0], role: 'resistance' });
|
|
return out;
|
|
}
|
|
|
|
interface TooltipState {
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
bar: OHLCVBar | null;
|
|
}
|
|
|
|
const MIN_VISIBLE_BARS = 10;
|
|
const CHART_HEIGHT = 440;
|
|
const VOLUME_PANE_HEIGHT = 72;
|
|
const PANE_GAP = 18;
|
|
|
|
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
|
|
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
|
|
const PRESET_MONTHS: Record<string, number> = { '1M': 1, '3M': 3, '6M': 6, '1Y': 12, '3Y': 36, '5Y': 60 };
|
|
const DEFAULT_PRESET: RangePreset = '1Y';
|
|
|
|
/** First bar index to show for a time-range preset (data is ascending by date). */
|
|
function startIndexForPreset(data: OHLCVBar[], preset: RangePreset): number {
|
|
if (preset === 'All' || data.length === 0) return 0;
|
|
const last = new Date(data[data.length - 1].date);
|
|
let cutoff: Date;
|
|
if (preset === 'YTD') {
|
|
cutoff = new Date(last.getFullYear(), 0, 1);
|
|
} else {
|
|
cutoff = new Date(last);
|
|
cutoff.setMonth(cutoff.getMonth() - PRESET_MONTHS[preset]);
|
|
}
|
|
const idx = data.findIndex((b) => new Date(b.date) >= cutoff);
|
|
return idx < 0 ? 0 : idx;
|
|
}
|
|
|
|
export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
|
|
const crosshairRef = useRef<{ x: number; y: number } | null>(null);
|
|
const animFrame = useRef<number>(0);
|
|
const zoomFrame = useRef<number>(0);
|
|
const isPanningRef = useRef<boolean>(false);
|
|
const panStartXRef = useRef<number>(0);
|
|
|
|
const [visibleRange, setVisibleRange] = useState<{ start: number; end: number }>({
|
|
start: 0,
|
|
end: data.length,
|
|
});
|
|
const [preset, setPreset] = useState<RangePreset>(DEFAULT_PRESET);
|
|
|
|
// Apply the active time-range preset when the data or preset changes (so the
|
|
// default view is a readable window, not the whole multi-year history).
|
|
useEffect(() => {
|
|
setVisibleRange({ start: startIndexForPreset(data, preset), end: data.length });
|
|
}, [data, preset]);
|
|
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const container = containerRef.current;
|
|
if (!canvas || !container || data.length === 0) return;
|
|
|
|
// Clamp visible range to valid bounds
|
|
const start = Math.max(0, visibleRange.start);
|
|
const end = Math.min(data.length, visibleRange.end);
|
|
const visibleData = data.slice(start, end);
|
|
if (visibleData.length === 0) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = container.getBoundingClientRect();
|
|
const W = rect.width;
|
|
const H = CHART_HEIGHT;
|
|
|
|
canvas.width = W * dpr;
|
|
canvas.height = H * dpr;
|
|
canvas.style.width = `${W}px`;
|
|
canvas.style.height = `${H}px`;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Margins
|
|
const ml = 12, mr = 70, mt = 12, mb = 32;
|
|
const cw = W - ml - mr;
|
|
const volumeH = VOLUME_PANE_HEIGHT;
|
|
const ch = H - mt - mb - volumeH - PANE_GAP;
|
|
const priceBottom = mt + ch;
|
|
const volumeTop = priceBottom + PANE_GAP;
|
|
const volumeBottom = volumeTop + volumeH;
|
|
|
|
// Current price = explicit prop, else latest close
|
|
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
|
// Only the nearest support/resistance are drawn — keep the chart legible
|
|
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
|
|
|
// Price range from visible data
|
|
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
|
const srPrices = markers.flatMap((m) => [m.low, m.high]);
|
|
const tradePrices = tradeSetup ? [tradeSetup.entry_price, tradeSetup.stop_loss, tradeSetup.target] : [];
|
|
const allVals = [...allPrices, ...srPrices, ...tradePrices, livePrice];
|
|
const minP = Math.min(...allVals);
|
|
const maxP = Math.max(...allVals);
|
|
const pad = (maxP - minP) * 0.06 || 1;
|
|
const lo = minP - pad;
|
|
const hi = maxP + pad;
|
|
|
|
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
|
const barW = cw / visibleData.length;
|
|
const candleW = Math.max(barW * 0.65, 1);
|
|
const volumeW = Math.max(barW * 0.65, 1);
|
|
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
|
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
|
|
|
// Grid lines (horizontal)
|
|
const nTicks = 6;
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
|
ctx.lineWidth = 1;
|
|
ctx.fillStyle = '#6b7280';
|
|
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
|
ctx.textAlign = 'right';
|
|
for (let i = 0; i <= nTicks; i++) {
|
|
const v = lo + ((hi - lo) * i) / nTicks;
|
|
const y = yScale(v);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, y);
|
|
ctx.lineTo(ml + cw, y);
|
|
ctx.stroke();
|
|
ctx.fillText(formatPrice(v), W - 8, y + 4);
|
|
}
|
|
|
|
// X-axis labels
|
|
ctx.textAlign = 'center';
|
|
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
|
|
for (let i = 0; i < visibleData.length; i += labelInterval) {
|
|
const x = ml + i * barW + barW / 2;
|
|
ctx.fillStyle = '#6b7280';
|
|
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
|
}
|
|
|
|
// Volume pane
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, volumeTop - 9);
|
|
ctx.lineTo(ml + cw, volumeTop - 9);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, volumeBottom);
|
|
ctx.lineTo(ml + cw, volumeBottom);
|
|
ctx.stroke();
|
|
|
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
|
ctx.fillStyle = '#6b7280';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText('Volume', ml, volumeTop - 13);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
|
|
|
|
visibleData.forEach((bar, i) => {
|
|
const x = ml + i * barW + barW / 2;
|
|
const bullish = bar.close >= bar.open;
|
|
const yVolume = volumeScale(bar.volume);
|
|
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
|
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
|
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
|
});
|
|
|
|
// Nearest support/resistance only (band if it came from a zone)
|
|
markers.forEach((m) => {
|
|
const isSupport = m.role === 'support';
|
|
const color = isSupport ? '#10b981' : '#ef4444';
|
|
const yMid = yScale(m.price);
|
|
|
|
if (m.high > m.low) {
|
|
const yTop = yScale(m.high);
|
|
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
|
|
ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)';
|
|
ctx.fillRect(ml, yTop, cw, rectHeight);
|
|
}
|
|
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1.25;
|
|
ctx.globalAlpha = 0.6;
|
|
ctx.setLineDash([6, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, yMid);
|
|
ctx.lineTo(ml + cw, yMid);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(
|
|
`${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`,
|
|
ml + cw + 4,
|
|
yMid + 3,
|
|
);
|
|
});
|
|
|
|
// Trade setup overlay (drawn before candles so candles render on top)
|
|
if (tradeSetup) {
|
|
const entryY = yScale(tradeSetup.entry_price);
|
|
const stopY = yScale(tradeSetup.stop_loss);
|
|
const targetY = yScale(tradeSetup.target);
|
|
|
|
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
|
|
const slTop = Math.min(entryY, stopY);
|
|
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
|
|
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
|
|
ctx.fillRect(ml, slTop, cw, slHeight);
|
|
// Stop-loss border
|
|
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, stopY);
|
|
ctx.lineTo(ml + cw, stopY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Take-profit zone: green semi-transparent rectangle between entry and target
|
|
const tpTop = Math.min(entryY, targetY);
|
|
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
|
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.13)';
|
|
ctx.fillRect(ml, tpTop, cw, tpHeight);
|
|
// Target border
|
|
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, targetY);
|
|
ctx.lineTo(ml + cw, targetY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Entry price: dashed horizontal line (blue/white)
|
|
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, entryY);
|
|
ctx.lineTo(ml + cw, entryY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Labels on right side
|
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
|
|
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
|
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
|
|
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
|
|
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
|
}
|
|
|
|
// Current price line — the anchor for everything else (drawn on top)
|
|
{
|
|
const py = yScale(livePrice);
|
|
ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)';
|
|
ctx.lineWidth = 1.25;
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, py);
|
|
ctx.lineTo(ml + cw, py);
|
|
ctx.stroke();
|
|
|
|
const label = `Now ${formatPrice(livePrice)}`;
|
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
|
const tw = ctx.measureText(label).width;
|
|
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
|
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
|
ctx.fillStyle = '#0e120f';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(label, ml + 6, py);
|
|
ctx.textBaseline = 'alphabetic';
|
|
}
|
|
|
|
// Candles
|
|
visibleData.forEach((bar, i) => {
|
|
const x = ml + i * barW + barW / 2;
|
|
const bullish = bar.close >= bar.open;
|
|
const color = bullish ? '#10b981' : '#ef4444';
|
|
|
|
const yHigh = yScale(bar.high);
|
|
const yLow = yScale(bar.low);
|
|
const yOpen = yScale(bar.open);
|
|
const yClose = yScale(bar.close);
|
|
|
|
// Wick
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yHigh);
|
|
ctx.lineTo(x, yLow);
|
|
ctx.stroke();
|
|
|
|
// Body
|
|
const bodyTop = Math.min(yOpen, yClose);
|
|
const bodyH = Math.max(Math.abs(yOpen - yClose), 1);
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
|
|
});
|
|
|
|
// Store geometry for hit testing (includes visibleRange offset)
|
|
(canvas as any).__chartMeta = {
|
|
ml,
|
|
mr,
|
|
mt,
|
|
mb,
|
|
cw,
|
|
ch,
|
|
barW,
|
|
lo,
|
|
hi,
|
|
yScale,
|
|
visibleStart: start,
|
|
volumeTop,
|
|
volumeH,
|
|
volumeBottom,
|
|
};
|
|
|
|
// Size the overlay canvas to match
|
|
const overlay = overlayCanvasRef.current;
|
|
if (overlay) {
|
|
overlay.width = W * dpr;
|
|
overlay.height = H * dpr;
|
|
overlay.style.width = `${W}px`;
|
|
overlay.style.height = `${H}px`;
|
|
}
|
|
}, [data, srLevels, visibleRange, zones, tradeSetup, currentPrice]);
|
|
|
|
const drawCrosshair = useCallback(() => {
|
|
const overlay = overlayCanvasRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!overlay || !canvas) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const ctx = overlay.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
|
|
const pos = crosshairRef.current;
|
|
if (!pos) return;
|
|
|
|
const meta = (canvas as any).__chartMeta;
|
|
if (!meta) return;
|
|
|
|
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
|
|
const H = overlay.height / dpr;
|
|
const priceBottom = mt + ch;
|
|
const chartBottom = volumeBottom ?? priceBottom;
|
|
|
|
// Clamp crosshair to chart area
|
|
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
|
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
|
|
|
|
// Dashed crosshair lines
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
|
ctx.lineWidth = 0.75;
|
|
ctx.setLineDash([4, 3]);
|
|
|
|
// Vertical line
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, mt);
|
|
ctx.lineTo(cx, chartBottom);
|
|
ctx.stroke();
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
|
const labelPadX = 5;
|
|
const labelPadY = 3;
|
|
|
|
if (cy <= priceBottom) {
|
|
// Horizontal price crosshair only belongs in the price pane.
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
|
ctx.lineWidth = 0.75;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ml, cy);
|
|
ctx.lineTo(ml + cw, cy);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Price label on y-axis (right side)
|
|
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
|
const priceText = formatPrice(price);
|
|
const priceMetrics = ctx.measureText(priceText);
|
|
const labelW = priceMetrics.width + labelPadX * 2;
|
|
const labelH = 16 + labelPadY * 2;
|
|
const labelX = ml + cw + 2;
|
|
const labelY = cy - labelH / 2;
|
|
|
|
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
|
ctx.beginPath();
|
|
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#e5e7eb';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(priceText, labelX + labelPadX, cy);
|
|
}
|
|
|
|
// Date label on x-axis (bottom)
|
|
const localIdx = Math.floor((cx - ml) / barW);
|
|
const absIdx = (visibleStart ?? 0) + localIdx;
|
|
if (absIdx >= 0 && absIdx < data.length) {
|
|
const dateText = formatDate(data[absIdx].date);
|
|
const dateMetrics = ctx.measureText(dateText);
|
|
const dateLabelW = dateMetrics.width + labelPadX * 2;
|
|
const dateLabelH = 16 + labelPadY * 2;
|
|
const dateLabelX = cx - dateLabelW / 2;
|
|
const dateLabelY = H - mb + 2;
|
|
|
|
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
|
ctx.beginPath();
|
|
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#e5e7eb';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
|
|
}
|
|
}, [data]);
|
|
|
|
useEffect(() => {
|
|
draw();
|
|
const onResize = () => {
|
|
cancelAnimationFrame(animFrame.current);
|
|
animFrame.current = requestAnimationFrame(() => {
|
|
draw();
|
|
drawCrosshair();
|
|
});
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
cancelAnimationFrame(animFrame.current);
|
|
};
|
|
}, [draw, drawCrosshair]);
|
|
|
|
// Prevent default scroll on wheel events over the overlay canvas
|
|
useEffect(() => {
|
|
const overlay = overlayCanvasRef.current;
|
|
if (!overlay) return;
|
|
const preventScroll = (e: WheelEvent) => {
|
|
if (data.length >= MIN_VISIBLE_BARS) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
overlay.addEventListener('wheel', preventScroll, { passive: false });
|
|
return () => overlay.removeEventListener('wheel', preventScroll);
|
|
}, [data.length]);
|
|
|
|
const handleWheel = useCallback(
|
|
(e: React.WheelEvent<HTMLCanvasElement>) => {
|
|
// Disable zoom if dataset has fewer than 10 bars
|
|
if (data.length < MIN_VISIBLE_BARS) return;
|
|
|
|
cancelAnimationFrame(zoomFrame.current);
|
|
zoomFrame.current = requestAnimationFrame(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const meta = (canvas as any).__chartMeta;
|
|
if (!meta) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
|
|
// Determine cursor position as fraction within the visible chart area
|
|
const cursorFraction = Math.max(0, Math.min(1, (mx - meta.ml) / meta.cw));
|
|
|
|
setVisibleRange((prev) => {
|
|
const currentWidth = prev.end - prev.start;
|
|
const zoomFactor = 0.1;
|
|
let delta: number;
|
|
|
|
if (e.deltaY > 0) {
|
|
// Scroll down → zoom out (widen range)
|
|
delta = Math.max(1, Math.round(currentWidth * zoomFactor));
|
|
} else {
|
|
// Scroll up → zoom in (narrow range)
|
|
delta = -Math.max(1, Math.round(currentWidth * zoomFactor));
|
|
}
|
|
|
|
const newWidth = currentWidth + delta;
|
|
|
|
// Clamp to min 10 bars, max full dataset
|
|
const clampedWidth = Math.max(MIN_VISIBLE_BARS, Math.min(data.length, newWidth));
|
|
|
|
if (clampedWidth === currentWidth) return prev;
|
|
|
|
const widthChange = clampedWidth - currentWidth;
|
|
|
|
// Distribute the change around the cursor position
|
|
const leftChange = Math.round(widthChange * cursorFraction);
|
|
const rightChange = widthChange - leftChange;
|
|
|
|
let newStart = prev.start - leftChange;
|
|
let newEnd = prev.end + rightChange;
|
|
|
|
// Clamp to dataset bounds
|
|
if (newStart < 0) {
|
|
newEnd -= newStart;
|
|
newStart = 0;
|
|
}
|
|
if (newEnd > data.length) {
|
|
newStart -= newEnd - data.length;
|
|
newEnd = data.length;
|
|
}
|
|
newStart = Math.max(0, newStart);
|
|
newEnd = Math.min(data.length, newEnd);
|
|
|
|
return { start: newStart, end: newEnd };
|
|
});
|
|
});
|
|
},
|
|
[data.length]
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
// Pan only when zoomed in (visible range < full dataset)
|
|
const currentWidth = visibleRange.end - visibleRange.start;
|
|
if (currentWidth >= data.length) return;
|
|
|
|
isPanningRef.current = true;
|
|
panStartXRef.current = e.clientX;
|
|
},
|
|
[data.length, visibleRange]
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
|
|
// Handle panning
|
|
if (isPanningRef.current) {
|
|
const meta = (canvas as any).__chartMeta;
|
|
if (!meta) return;
|
|
|
|
const dx = e.clientX - panStartXRef.current;
|
|
// Convert pixel drag to bar count: negative dx = drag left = shift range right (see later bars)
|
|
const barShift = -Math.round(dx / meta.barW);
|
|
|
|
if (barShift !== 0) {
|
|
panStartXRef.current = e.clientX;
|
|
|
|
setVisibleRange((prev) => {
|
|
const width = prev.end - prev.start;
|
|
let newStart = prev.start + barShift;
|
|
let newEnd = prev.end + barShift;
|
|
|
|
// Clamp to dataset bounds
|
|
if (newStart < 0) {
|
|
newStart = 0;
|
|
newEnd = width;
|
|
}
|
|
if (newEnd > data.length) {
|
|
newEnd = data.length;
|
|
newStart = data.length - width;
|
|
}
|
|
|
|
if (newStart === prev.start && newEnd === prev.end) return prev;
|
|
return { start: newStart, end: newEnd };
|
|
});
|
|
}
|
|
|
|
// Clear crosshair while panning
|
|
crosshairRef.current = null;
|
|
drawCrosshair();
|
|
return;
|
|
}
|
|
|
|
// Update crosshair position and draw
|
|
crosshairRef.current = { x: mx, y: my };
|
|
drawCrosshair();
|
|
|
|
// Tooltip logic
|
|
const tip = tooltipRef.current;
|
|
if (!tip || data.length === 0) return;
|
|
|
|
const meta = (canvas as any).__chartMeta;
|
|
if (!meta) return;
|
|
|
|
const localIdx = Math.floor((mx - meta.ml) / meta.barW);
|
|
|
|
// Map local index to the visible data slice
|
|
const visibleStart = meta.visibleStart ?? 0;
|
|
const visibleEnd = Math.min(data.length, visibleStart + Math.round(meta.cw / meta.barW));
|
|
const absIdx = visibleStart + localIdx;
|
|
|
|
if (localIdx >= 0 && absIdx < visibleEnd && absIdx < data.length) {
|
|
const bar = data[absIdx];
|
|
tooltipState.current = { visible: true, x: mx, y: my, bar };
|
|
tip.style.display = 'block';
|
|
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
|
|
tip.style.top = `${Math.max(my - 80, 8)}px`;
|
|
|
|
// Check if cursor is near trade overlay zone
|
|
let tradeTooltipHtml = '';
|
|
if (tradeSetup && meta.yScale) {
|
|
const entryY = meta.yScale(tradeSetup.entry_price);
|
|
const stopY = meta.yScale(tradeSetup.stop_loss);
|
|
const targetY = meta.yScale(tradeSetup.target);
|
|
const tradeTop = Math.min(entryY, stopY, targetY);
|
|
const tradeBottom = Math.max(entryY, stopY, targetY);
|
|
if (my >= tradeTop - 10 && my <= tradeBottom + 10) {
|
|
tradeTooltipHtml = `
|
|
<div class="border-t border-gray-600 mt-1.5 pt-1.5 text-gray-300 font-medium mb-1">Trade Setup</div>
|
|
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
|
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
|
|
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
|
|
<span>Stop</span><span class="text-right text-red-400">${formatPrice(tradeSetup.stop_loss)}</span>
|
|
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
|
|
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
tip.innerHTML = `
|
|
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
|
|
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
|
<span>Open</span><span class="text-right text-gray-200">${formatPrice(bar.open)}</span>
|
|
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
|
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
|
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
|
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
|
|
</div>${tradeTooltipHtml}`;
|
|
} else {
|
|
tip.style.display = 'none';
|
|
}
|
|
},
|
|
[data, drawCrosshair, tradeSetup]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
isPanningRef.current = false;
|
|
}, []);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
isPanningRef.current = false;
|
|
crosshairRef.current = null;
|
|
drawCrosshair();
|
|
const tip = tooltipRef.current;
|
|
if (tip) tip.style.display = 'none';
|
|
}, [drawCrosshair]);
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center text-gray-500">
|
|
No OHLCV data available
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="mb-2 flex flex-wrap items-center gap-1">
|
|
{RANGE_PRESETS.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => {
|
|
// Re-apply the range directly so clicking the active preset still
|
|
// snaps back after a manual wheel-zoom / pan.
|
|
setPreset(p);
|
|
setVisibleRange({ start: startIndexForPreset(data, p), end: data.length });
|
|
}}
|
|
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
|
preset === p ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
))}
|
|
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
|
</div>
|
|
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full"
|
|
style={{ height: CHART_HEIGHT }}
|
|
/>
|
|
<canvas
|
|
ref={overlayCanvasRef}
|
|
className="absolute top-0 left-0 w-full cursor-crosshair"
|
|
style={{ height: CHART_HEIGHT }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
onWheel={handleWheel}
|
|
/>
|
|
<div
|
|
ref={tooltipRef}
|
|
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|