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 = { '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(null); const overlayCanvasRef = useRef(null); const containerRef = useRef(null); const tooltipRef = useRef(null); const tooltipState = useRef({ visible: false, x: 0, y: 0, bar: null }); const crosshairRef = useRef<{ x: number; y: number } | null>(null); const animFrame = useRef(0); const zoomFrame = useRef(0); const isPanningRef = useRef(false); const panStartXRef = useRef(0); const [visibleRange, setVisibleRange] = useState<{ start: number; end: number }>({ start: 0, end: data.length, }); const [preset, setPreset] = useState(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) => { // 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) => { // 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) => { 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 = `
Trade Setup
Direction${tradeSetup.direction} Entry${formatPrice(tradeSetup.entry_price)} Stop${formatPrice(tradeSetup.stop_loss)} Target${formatPrice(tradeSetup.target)} R:R${tradeSetup.rr_ratio.toFixed(2)}
`; } } tip.innerHTML = `
${formatDate(bar.date)}
Open${formatPrice(bar.open)} High${formatPrice(bar.high)} Low${formatPrice(bar.low)} Close${formatPrice(bar.close)} Vol${formatLargeNumber(bar.volume)}
${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 (
No OHLCV data available
); } return (
{RANGE_PRESETS.map((p) => ( ))} scroll to zoom · drag to pan
); }