import { useState, useEffect, useRef } from 'react'; import { ResponsiveContainer, ComposedChart, Area, Bar, Line, XAxis, YAxis, Tooltip, ReferenceLine, LineChart } from 'recharts'; import { AreaChart as AreaIcon, BarChart2 as CandleIcon, Activity, Eye, EyeOff, Maximize2, Minimize2 } from 'lucide-react'; import ChartChatbot from './ChartChatbot'; interface ChartDataPoint { time: string; price: number; open?: number; high?: number; low?: number; close?: number; range?: [number, number]; } interface StockChartProps { activeTicker: string; chartData: ChartDataPoint[]; activeStats: any; chartRange: string; onRangeChange: (range: string) => void; user?: any; onOpenRecharge?: () => void; } // Custom SVG component to draw wicks and body for candlestick bars const CandlestickBar = (props: any) => { const { x, y, width, height, minPrice } = props; // Extract values safely from props or nested payload const open = props.open !== undefined ? props.open : props.payload?.open; const close = props.close !== undefined ? props.close : props.payload?.close; const high = props.high !== undefined ? props.high : props.payload?.high; const low = props.low !== undefined ? props.low : props.payload?.low; if (open === undefined || close === undefined || high === undefined || low === undefined || minPrice === undefined) return null; const isUp = close >= open; const color = isUp ? '#10b981' : '#ef4444'; // green or red // Calculate scale: pixels per unit price // The bottom of the bar corresponds to minPrice. The top of the bar (y) corresponds to close. const priceRange = close - minPrice; const scale = priceRange > 0 ? height / priceRange : 1; // Project prices to SVG coordinates const yClose = y; const yOpen = y + (close - open) * scale; const yHigh = y + (close - high) * scale; const yLow = y + (close - low) * scale; const cx = x + width / 2; const rectY = Math.min(yClose, yOpen); const rectHeight = Math.max(Math.abs(yClose - yOpen), 2); // Ensure at least 2px height for body // For dense charts (like MAX range), bars can become less than 1px. We cap the minimum body width to 2px so it remains visible. const bodyWidth = Math.max(width, 2); const bodyX = x - (bodyWidth - width) / 2; // Center the body return ( {/* Wick / Shadow (vertical line from high to low) */} {/* Candle Body */} ); }; // Custom premium Tooltip component for stock chart hover stats const CustomTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { const data = payload[0].payload; const isCandle = data.open !== undefined && data.close !== undefined; return (
{label}
{isCandle ? (
Open ${data.open.toFixed(2)}
High ${data.high?.toFixed(2)}
Low ${data.low?.toFixed(2)}
Close ${data.close.toFixed(2)}
) : (
Price ${data.price.toFixed(2)}
)} {payload.map((entry: any, index: number) => { if (entry.dataKey === 'sma' && entry.value !== undefined && entry.value !== null) { return (
SMA 20 ${entry.value.toFixed(2)}
); } if (entry.dataKey === 'ema' && entry.value !== undefined && entry.value !== null) { return (
EMA 20 ${entry.value.toFixed(2)}
); } return null; })}
); } return null; }; export default function StockChart({ activeTicker, chartData, activeStats, chartRange, onRangeChange, user, onOpenRecharge }: StockChartProps) { const currentPrice = chartData.length > 0 ? chartData[chartData.length - 1].price : null; // Chart States const [chartType, setChartType] = useState<'line' | 'candle'>(() => { const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; const saved = localStorage.getItem(`quantiq_chart_type_${email}_${activeTicker}`); return (saved as 'line' | 'candle') || 'line'; }); const [showSMA, setShowSMA] = useState(() => { const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; const saved = localStorage.getItem(`quantiq_show_sma_${email}_${activeTicker}`); return saved === 'true'; }); const [showEMA, setShowEMA] = useState(() => { const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; const saved = localStorage.getItem(`quantiq_show_ema_${email}_${activeTicker}`); return saved === 'true'; }); const [showRSI, setShowRSI] = useState(() => { const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; const saved = localStorage.getItem(`quantiq_show_rsi_${email}_${activeTicker}`); return saved === 'true'; }); const [isMaximized, setIsMaximized] = useState(() => { return sessionStorage.getItem('quantiq_restore_maximized_chart') === 'true'; }); // Track email & clear restoration flag on mount useEffect(() => { if (user?.email) { localStorage.setItem('quantiq_last_logged_in_email', user.email); } if (sessionStorage.getItem('quantiq_restore_maximized_chart') === 'true') { sessionStorage.removeItem('quantiq_restore_maximized_chart'); } }, [user]); // Persist chartType, SMA, EMA, and RSI states on change useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; localStorage.setItem(`quantiq_chart_type_${email}_${activeTicker}`, chartType); }, [chartType, activeTicker, user]); useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; localStorage.setItem(`quantiq_show_sma_${email}_${activeTicker}`, String(showSMA)); }, [showSMA, activeTicker, user]); useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; localStorage.setItem(`quantiq_show_ema_${email}_${activeTicker}`, String(showEMA)); }, [showEMA, activeTicker, user]); useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; localStorage.setItem(`quantiq_show_rsi_${email}_${activeTicker}`, String(showRSI)); }, [showRSI, activeTicker, user]); // Zoom Factor and Scroll/Pan states — active always (Binance-style interactions) const [zoomFactor, setZoomFactor] = useState(1.0); const [scrollIndex, setScrollIndex] = useState(0); // Drag-to-pan state refs (no re-render on drag move) const isDragging = useRef(false); const dragStartX = useRef(0); const dragStartScrollIndex = useRef(0); // Ref to the chart canvas div — used for non-passive wheel listener const chartCanvasRef = useRef(null); // Reset zoom and scroll when ticker or range changes useEffect(() => { setZoomFactor(1.0); setScrollIndex(0); }, [activeTicker, chartRange]); // Adjust scroll position to latest candles whenever zoom factor changes useEffect(() => { if (zoomFactor >= 1.0) { setScrollIndex(0); } else { const visibleCount = Math.max(5, Math.round(chartData.length * zoomFactor)); const maxScroll = Math.max(0, chartData.length - visibleCount); setScrollIndex(prev => Math.min(prev, maxScroll)); } }, [zoomFactor, chartData.length]); // Non-passive wheel listener on the chart canvas — the ONLY way to call // e.preventDefault() in Chrome and stop page scroll while hovering the chart. // React's synthetic onWheel is passive by default since React 17. // // CRITICAL: The chart canvas div renders CONDITIONALLY (only when processedData // has items), so we depend on processedData.length to re-run this effect the // moment data loads and chartCanvasRef.current becomes valid. useEffect(() => { const el = chartCanvasRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); // stops BOTH page scroll AND browser pinch-zoom const zoomStep = 0.08; if (e.deltaY < 0) { setZoomFactor(prev => Math.max(0.1, prev - zoomStep)); } else { setZoomFactor(prev => Math.min(1.0, prev + zoomStep)); } }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [chartData.length]); // re-run when data availability changes // Markers state for drawing entry/exit lines interface ChartMarker { id: string; price: number; label: string; color: string; } const [markers, setMarkers] = useState(() => { const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; if (activeTicker) { const saved = localStorage.getItem(`quantiq_markers_${email}_${activeTicker}`); if (saved) { try { return JSON.parse(saved); } catch (e) { console.error('Failed to parse saved markers:', e); } } } return []; }); // Reload markers on ticker/user change useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; if (activeTicker) { const saved = localStorage.getItem(`quantiq_markers_${email}_${activeTicker}`); if (saved) { try { setMarkers(JSON.parse(saved)); return; } catch (e) { console.error('Failed to parse saved markers:', e); } } } setMarkers([]); }, [activeTicker, user?.email]); // Save markers to localStorage when updated useEffect(() => { const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; if (activeTicker) { localStorage.setItem(`quantiq_markers_${email}_${activeTicker}`, JSON.stringify(markers)); } }, [markers, activeTicker, user?.email]); const [showAddMarkerForm, setShowAddMarkerForm] = useState(false); const [newMarkerLabel, setNewMarkerLabel] = useState('Entry'); const [newMarkerPrice, setNewMarkerPrice] = useState(''); const [newMarkerColor, setNewMarkerColor] = useState('#10b981'); // Default green const ranges = [ { label: '1D', value: '1d' }, { label: '5D', value: '5d' }, { label: '1M', value: '1m' }, { label: '6M', value: '6m' }, { label: 'YTD', value: 'ytd' }, { label: '1Y', value: '1y' }, { label: '5Y', value: '5y' }, { label: 'MAX', value: 'max' } ]; // Helper: compute SMA 20 const computeSMA = (data: any[], period: number = 20) => { return data.map((d, i) => { if (i < period - 1) return { ...d, sma: null }; const slice = data.slice(i - period + 1, i + 1); const sum = slice.reduce((acc, curr) => acc + curr.price, 0); return { ...d, sma: parseFloat((sum / period).toFixed(2)) }; }); }; // Helper: compute EMA 20 const computeEMA = (data: any[], period: number = 20) => { const k = 2 / (period + 1); let prevEma = data.length > 0 ? data[0].price : 0; return data.map((d, i) => { if (i === 0) return { ...d, ema: prevEma }; const ema = d.price * k + prevEma * (1 - k); prevEma = ema; return { ...d, ema: parseFloat(ema.toFixed(2)) }; }); }; // Helper: compute RSI 14 const computeRSI = (data: any[], period: number = 14) => { if (data.length === 0) return []; let gains = 0; let losses = 0; // Initial gains/losses for (let i = 1; i <= period && i < data.length; i++) { const diff = data[i].price - data[i - 1].price; if (diff > 0) gains += diff; else losses -= diff; } let avgGain = gains / period; let avgLoss = losses / period; return data.map((d, i) => { if (i <= period) return { ...d, rsi: 50 }; // default neutral const diff = d.price - data[i - 1].price; const gain = diff > 0 ? diff : 0; const loss = diff < 0 ? -diff : 0; avgGain = (avgGain * (period - 1) + gain) / period; avgLoss = (avgLoss * (period - 1) + loss) / period; if (avgLoss === 0) return { ...d, rsi: 100 }; const rs = avgGain / avgLoss; const rsi = 100 - 100 / (1 + rs); return { ...d, rsi: parseFloat(rsi.toFixed(2)) }; }); }; // Pipeline processed chart data let processedData = [...chartData]; if (showSMA) processedData = computeSMA(processedData, 20); if (showEMA) processedData = computeEMA(processedData, 20); // Compute full set of indicators for chatbot live coordinates context const chatbotData = computeRSI(computeEMA(computeSMA([...chartData], 20), 20), 14); const latestDataPoint = chatbotData[chatbotData.length - 1] || null; const chatbotLiveData = { price: latestDataPoint?.close || latestDataPoint?.price || activeStats?.close || null, sma: latestDataPoint?.sma || null, ema: latestDataPoint?.ema || null, rsi: latestDataPoint?.rsi || null }; // Slice data based on zoom — works in both normal and maximized views const isZoomed = zoomFactor < 1.0; const visibleCount = Math.max(5, Math.round(processedData.length * zoomFactor)); const maxScrollIndex = Math.max(0, processedData.length - visibleCount); const currentScrollIndex = isZoomed ? Math.min(scrollIndex, maxScrollIndex) : 0; const visibleData = isZoomed ? processedData.slice(currentScrollIndex, currentScrollIndex + visibleCount) : processedData; const rsiData = showRSI ? computeRSI(chartData, 14) : []; const visibleRsiData = isZoomed ? rsiData.slice(currentScrollIndex, currentScrollIndex + visibleCount) : rsiData; // Calculate global Y-axis domain bounds dynamically to prevent clipping // Filter to only finite, positive numbers to prevent garbage values like NaN/Infinity // from corrupting Math.min/max and producing labels like 'i1499999986' const filterFinite = (arr: number[]) => arr.filter(v => typeof v === 'number' && isFinite(v) && v > 0); const prices = filterFinite(visibleData.map(d => d.price)); const highs = filterFinite(visibleData.map(d => d.high ?? d.price)); const lows = filterFinite(visibleData.map(d => d.low ?? d.price)); const rawMinPrice = prices.length > 0 ? Math.min(...lows.length > 0 ? lows : prices, ...prices) : 0; const rawMaxPrice = prices.length > 0 ? Math.max(...highs.length > 0 ? highs : prices, ...prices) : 100; // Add 5% padding to top/bottom to prevent lines touching borders const yMin = rawMinPrice === rawMaxPrice ? rawMinPrice * 0.95 : (rawMinPrice > 0 ? rawMinPrice * 0.95 : rawMinPrice); const yMax = rawMinPrice === rawMaxPrice ? rawMaxPrice * 1.05 : rawMaxPrice * 1.05; // Mouse-wheel zoom is now handled via non-passive native addEventListener above. // Drag-to-pan handlers (Binance-style: click + drag left/right to pan) const handleMouseDown = (e: React.MouseEvent) => { if (!isZoomed) return; isDragging.current = true; dragStartX.current = e.clientX; dragStartScrollIndex.current = currentScrollIndex; }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging.current || !isZoomed) return; // Each ~8px of mouse movement = 1 candle shift const deltaX = dragStartX.current - e.clientX; const canvasWidth = e.currentTarget.clientWidth; const pixelsPerCandle = canvasWidth / visibleCount; const candleShift = Math.round(deltaX / pixelsPerCandle); const newIndex = Math.max(0, Math.min(dragStartScrollIndex.current + candleShift, maxScrollIndex)); setScrollIndex(newIndex); }; const handleMouseUp = () => { isDragging.current = false; }; const handleMouseLeave = () => { isDragging.current = false; }; const chartPanelStyle: React.CSSProperties = isMaximized ? { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 9999, background: 'rgba(9, 11, 20, 0.98)', backdropFilter: 'blur(24px)', padding: '20px 24px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', borderRadius: 0, border: 'none', overflow: 'hidden' } : {}; return (

{activeTicker}

{currentPrice !== null ? `$${currentPrice.toFixed(2)}` : '—'}
Live
{ranges.map((r) => ( ))}
{/* Horizontal Flex Split View when Maximized */}
{/* Left Column: Metrics grid, controls, and charts */}
{activeStats && (
Open ${activeStats.open.toFixed(2)}
High ${activeStats.high.toFixed(2)}
Low ${activeStats.low.toFixed(2)}
Volume {activeStats.volume.toLocaleString()}
Change = 0 ? 'text-bull' : 'text-bear'}`}> ${activeStats.change.toFixed(2)}
Range Change % = 0 ? 'text-bull' : 'text-bear'}`}> {activeStats.changePercent >= 0 ? '+' : ''}{activeStats.changePercent.toFixed(2)}%
)} {/* Technical Indicator Controls row */}
Chart Type
Technical Overlays
{/* Maximized-only marker controls bar */} {isMaximized && (
Chart Markers {/* Active markers list with delete buttons */} {markers.length > 0 && (
| Active: {markers.map(m => ( {m.label} (${m.price.toFixed(2)}) ))}
)} {/* Zoom level badge + reset — replaces old zoom buttons */}
{isZoomed && ( <> {Math.round((1 / zoomFactor) * 100)}% zoom )} {!isZoomed && ( Scroll to zoom · Drag to pan )}
)} {/* Main Chart Area */}
{/* Floating Add Marker Form */} {showAddMarkerForm && (
Add Reference Marker
setNewMarkerLabel(e.target.value)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--border-glass)', borderRadius: '6px', padding: '6px 8px', color: '#fff', fontSize: '12px', outline: 'none' }} placeholder="e.g. Entry, TP 1, Stop Loss" />
setNewMarkerPrice(e.target.value)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--border-glass)', borderRadius: '6px', padding: '6px 8px', color: '#fff', fontSize: '12px', outline: 'none' }} placeholder="e.g. 60000" />
{[ { value: '#10b981', label: 'Green' }, { value: '#ef4444', label: 'Red' }, { value: '#00f2fe', label: 'Cyan' }, { value: '#fb923c', label: 'Orange' }, { value: '#a154ff', label: 'Purple' } ].map((c) => (
)} {processedData.length > 0 ? (
{ if (!isFinite(v) || isNaN(v)) return ''; if (v >= 1000) return `${(v / 1000).toFixed(1)}k`; return v.toFixed(2); }} /> } /> {/* User Defined Markers / Reference Lines */} {markers.map((marker) => ( ))} {/* Primary Chart Type Representation */} {chartType === 'line' ? ( ) : ( } name="Candlestick" /> )} {/* Moving Average Indicators */} {showSMA && ( )} {showEMA && ( )}
) : (
{chartRange === '1d' ? `Waiting for live ${activeTicker} ticks from Redpanda...` : `No historical data found for ${activeTicker}.` } Please verify if the ticker exists on Yahoo Finance (e.g., ADANIENT.NS for Adani Enterprises, GOOGL for Google).
)} {/* Zoomed date range indicator (replaces PAN CHART slider) */} {processedData.length > 0 && isZoomed && (
{visibleData[0]?.time} {visibleData[visibleData.length - 1]?.time}
)} {/* Technical Indicator panel: RSI 14 (Auxiliary Chart below main price chart) */} {showRSI && visibleRsiData.length > 0 && (
RSI (14) Overbought >70 | Oversold <30
{/* Baseline references */}
)}
{/* closes chart-container */}
{/* closes Left Column */} {/* Right Column: AI Chatbot (Only rendered in maximized layout) */} {isMaximized && ( )}
); }