diff --git a/frontend/src/App.css b/frontend/src/App.css index b0e8286..8df0dea 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -520,6 +520,80 @@ .right-sidebar { grid-column: span 1; } + + .chart-indicator-controls { + flex-direction: column; + align-items: stretch !important; + gap: 12px !important; + padding: 12px !important; + } + + .chart-type-controls, + .technical-overlay-controls { + width: 100%; + max-width: 100%; + flex-wrap: wrap; + align-items: center; + } + + .chart-type-controls { + justify-content: flex-start; + } + + .technical-overlay-controls { + justify-content: flex-start; + } + + .technical-overlay-controls .control-toggle-btn { + flex: 1 1 calc(50% - 4px); + min-width: 0; + min-height: 38px; + justify-content: center; + } + + .chart-range-actions { + width: 100%; + max-width: 100%; + display: flex; + align-items: center; + gap: 8px !important; + } + + .chart-range-selector { + flex: 1 1 auto; + min-width: 0; + width: auto !important; + padding: 2px; + gap: 0; + overflow: hidden; + } + + .range-btn { + flex: 1 1 0; + min-width: 0; + padding: 3px 1px; + font-size: 7px; + height: 30px; + } + + .chart-range-actions > .control-toggle-btn { + flex: 0 0 34px; + width: 34px; + min-width: 34px; + height: 34px; + padding: 0 !important; + border-radius: 7px !important; + display: flex; + align-items: center; + justify-content: center; + } +} + +@media (max-width: 768px) { + .chart-range-actions .range-btn { + font-size: 10px !important; + padding: 4px 2px !important; + } } /* ─── Glass Panel Base ──────────────────── */ diff --git a/frontend/src/components/StockChart.tsx b/frontend/src/components/StockChart.tsx index 36f8a10..514efa7 100644 --- a/frontend/src/components/StockChart.tsx +++ b/frontend/src/components/StockChart.tsx @@ -1,10 +1,30 @@ -import { useState, useEffect, useRef } from 'react'; -import { ResponsiveContainer, ComposedChart, Area, Bar, Cell, 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'; +import { useState, useEffect, useRef } from "react"; +import { + ResponsiveContainer, + ComposedChart, + Area, + Bar, + Cell, + 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"; // Backend REST base URL (matches the rest of the frontend) -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; interface ChartDataPoint { time: string; @@ -41,34 +61,40 @@ interface StockChartProps { // 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; + 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 - + 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 @@ -76,21 +102,21 @@ const CandlestickBar = (props: any) => { return ( {/* Wick / Shadow (vertical line from high to low) */} - {/* Candle Body */} - @@ -103,88 +129,223 @@ 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)} +
+
+ Open + + ${data.open.toFixed(2)} +
-
- High - ${data.high?.toFixed(2)} +
+ High + + ${data.high?.toFixed(2)} +
-
- Low - ${data.low?.toFixed(2)} +
+ Low + + ${data.low?.toFixed(2)} +
-
- Close - ${data.close.toFixed(2)} +
+ Close + + ${data.close.toFixed(2)} +
) : ( -
- Price - ${data.price.toFixed(2)} +
+ Price + + ${data.price.toFixed(2)} +
)} - + {payload.map((entry: any, index: number) => { - if (entry.dataKey === 'sma' && entry.value !== undefined && entry.value !== null) { + if ( + entry.dataKey === "sma" && + entry.value !== undefined && + entry.value !== null + ) { return ( -
+
SMA 20 - ${entry.value.toFixed(2)} + + ${entry.value.toFixed(2)} +
); } - if (entry.dataKey === 'ema' && entry.value !== undefined && entry.value !== null) { + if ( + entry.dataKey === "ema" && + entry.value !== undefined && + entry.value !== null + ) { return ( -
+
EMA 20 - ${entry.value.toFixed(2)} + + ${entry.value.toFixed(2)} +
); } - if (entry.dataKey === 'bb_upper' && entry.value !== undefined && entry.value !== null) { + if ( + entry.dataKey === "bb_upper" && + entry.value !== undefined && + entry.value !== null + ) { return ( -
+
BB Upper - ${entry.value.toFixed(2)} + + ${entry.value.toFixed(2)} +
); } - if (entry.dataKey === 'bb_middle' && entry.value !== undefined && entry.value !== null) { + if ( + entry.dataKey === "bb_middle" && + entry.value !== undefined && + entry.value !== null + ) { return ( -
+
BB Middle - ${entry.value.toFixed(2)} + + ${entry.value.toFixed(2)} +
); } - if (entry.dataKey === 'bb_lower' && entry.value !== undefined && entry.value !== null) { + if ( + entry.dataKey === "bb_lower" && + entry.value !== undefined && + entry.value !== null + ) { return ( -
+
BB Lower - ${entry.value.toFixed(2)} + + ${entry.value.toFixed(2)} +
); } @@ -200,9 +361,13 @@ const CustomTooltip = ({ active, payload, label }: any) => { // data, keyed by each candle's epoch timestamp (ts). Any candle without a matching // backend value (e.g. live ticks appended after the fetch) falls back to null so // recharts simply leaves a gap instead of drawing garbage. -const mergeIndicators = (data: ChartDataPoint[], ind: any): ChartDataPoint[] => { +const mergeIndicators = ( + data: ChartDataPoint[], + ind: any, +): ChartDataPoint[] => { if (!ind) return data; - const toMap = (arr?: any[]) => new Map((arr || []).map((p: any) => [p.time, p.value])); + const toMap = (arr?: any[]) => + new Map((arr || []).map((p: any) => [p.time, p.value])); const bbUpper = toMap(ind.bollinger_bands?.upper); const bbMiddle = toMap(ind.bollinger_bands?.middle); @@ -220,90 +385,154 @@ const mergeIndicators = (data: ChartDataPoint[], ind: any): ChartDataPoint[] => bb_middle: t !== undefined && bbMiddle.has(t) ? bbMiddle.get(t) : null, bb_lower: t !== undefined && bbLower.has(t) ? bbLower.get(t) : null, macd: t !== undefined && macdLine.has(t) ? macdLine.get(t) : null, - macd_signal: t !== undefined && macdSignal.has(t) ? macdSignal.get(t) : null, + macd_signal: + t !== undefined && macdSignal.has(t) ? macdSignal.get(t) : null, macd_hist: t !== undefined && macdHist.has(t) ? macdHist.get(t) : null, rsi: t !== undefined && rsiVal.has(t) ? rsiVal.get(t) : null, }; }); }; -export default function StockChart({ activeTicker, chartData, activeStats, chartRange, onRangeChange, user, onOpenRecharge }: StockChartProps) { - const currentPrice = chartData.length > 0 ? chartData[chartData.length - 1].price : 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 [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 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 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 email = + localStorage.getItem("quantiq_last_logged_in_email") || "generic"; + const saved = localStorage.getItem( + `quantiq_show_rsi_${email}_${activeTicker}`, + ); + return saved === "true"; }); const [showMACD, setShowMACD] = useState(() => { - const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; - const saved = localStorage.getItem(`quantiq_show_macd_${email}_${activeTicker}`); - return saved === 'true'; + const email = + localStorage.getItem("quantiq_last_logged_in_email") || "generic"; + const saved = localStorage.getItem( + `quantiq_show_macd_${email}_${activeTicker}`, + ); + return saved === "true"; }); const [showBB, setShowBB] = useState(() => { - const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; - const saved = localStorage.getItem(`quantiq_show_bb_${email}_${activeTicker}`); - return saved === 'true'; + const email = + localStorage.getItem("quantiq_last_logged_in_email") || "generic"; + const saved = localStorage.getItem( + `quantiq_show_bb_${email}_${activeTicker}`, + ); + return saved === "true"; }); const [isMaximized, setIsMaximized] = useState(() => { - return sessionStorage.getItem('quantiq_restore_maximized_chart') === 'true'; + 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); + localStorage.setItem("quantiq_last_logged_in_email", user.email); } - if (sessionStorage.getItem('quantiq_restore_maximized_chart') === 'true') { - sessionStorage.removeItem('quantiq_restore_maximized_chart'); + 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); + 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)); + 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)); + 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)); + const email = + user?.email || + localStorage.getItem("quantiq_last_logged_in_email") || + "generic"; + localStorage.setItem( + `quantiq_show_rsi_${email}_${activeTicker}`, + String(showRSI), + ); }, [showRSI, activeTicker, user]); useEffect(() => { - const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; - localStorage.setItem(`quantiq_show_macd_${email}_${activeTicker}`, String(showMACD)); + const email = + user?.email || + localStorage.getItem("quantiq_last_logged_in_email") || + "generic"; + localStorage.setItem( + `quantiq_show_macd_${email}_${activeTicker}`, + String(showMACD), + ); }, [showMACD, activeTicker, user]); useEffect(() => { - const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; - localStorage.setItem(`quantiq_show_bb_${email}_${activeTicker}`, String(showBB)); + const email = + user?.email || + localStorage.getItem("quantiq_last_logged_in_email") || + "generic"; + localStorage.setItem( + `quantiq_show_bb_${email}_${activeTicker}`, + String(showBB), + ); }, [showBB, activeTicker, user]); // ── Backend-computed indicators (MACD + Bollinger Bands) ── @@ -324,20 +553,23 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart const url = `${API_URL}/api/v1/indicators?ticker=${encodeURIComponent(activeTicker)}&range=${encodeURIComponent(chartRange)}`; fetch(url) .then((res) => { - if (!res.ok) throw new Error(`Indicators API responded with ${res.status}`); + if (!res.ok) + throw new Error(`Indicators API responded with ${res.status}`); return res.json(); }) .then((data) => { if (!cancelled) setIndicatorData(data); }) .catch((err) => { - console.error('Failed to load chart indicators:', err); + console.error("Failed to load chart indicators:", err); if (!cancelled) setIndicatorData(null); }) .finally(() => { if (!cancelled) setIndicatorLoading(false); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [activeTicker, chartRange, showMACD, showBB]); // Zoom Factor and Scroll/Pan states — active always (Binance-style interactions) @@ -362,9 +594,12 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart if (zoomFactor >= 1.0) { setScrollIndex(0); } else { - const visibleCount = Math.max(5, Math.round(chartData.length * zoomFactor)); + const visibleCount = Math.max( + 5, + Math.round(chartData.length * zoomFactor), + ); const maxScroll = Math.max(0, chartData.length - visibleCount); - setScrollIndex(prev => Math.min(prev, maxScroll)); + setScrollIndex((prev) => Math.min(prev, maxScroll)); } }, [zoomFactor, chartData.length]); @@ -382,13 +617,13 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart 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)); + setZoomFactor((prev) => Math.max(0.1, prev - zoomStep)); } else { - setZoomFactor(prev => Math.min(1.0, prev + zoomStep)); + setZoomFactor((prev) => Math.min(1.0, prev + zoomStep)); } }; - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); + 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 @@ -399,14 +634,17 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart color: string; } const [markers, setMarkers] = useState(() => { - const email = localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; + const email = + localStorage.getItem("quantiq_last_logged_in_email") || "generic"; if (activeTicker) { - const saved = localStorage.getItem(`quantiq_markers_${email}_${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); + console.error("Failed to parse saved markers:", e); } } } @@ -415,15 +653,20 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart // Reload markers on ticker/user change useEffect(() => { - const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; + const email = + user?.email || + localStorage.getItem("quantiq_last_logged_in_email") || + "generic"; if (activeTicker) { - const saved = localStorage.getItem(`quantiq_markers_${email}_${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); + console.error("Failed to parse saved markers:", e); } } } @@ -432,25 +675,31 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart // Save markers to localStorage when updated useEffect(() => { - const email = user?.email || localStorage.getItem('quantiq_last_logged_in_email') || 'generic'; + const email = + user?.email || + localStorage.getItem("quantiq_last_logged_in_email") || + "generic"; if (activeTicker) { - localStorage.setItem(`quantiq_markers_${email}_${activeTicker}`, JSON.stringify(markers)); + 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 [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' } + { 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 @@ -478,30 +727,30 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart // 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); @@ -516,30 +765,44 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart 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 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, + price: + latestDataPoint?.close || + latestDataPoint?.price || + activeStats?.close || + null, sma: latestDataPoint?.sma || null, ema: latestDataPoint?.ema || null, - rsi: latestDataPoint?.rsi || 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 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 currentScrollIndex = isZoomed + ? Math.min(scrollIndex, maxScrollIndex) + : 0; const visibleData = isZoomed ? processedData.slice(currentScrollIndex, currentScrollIndex + visibleCount) : processedData; - + const rsiData = showRSI ? computeRSI(chartData, 14) : []; const visibleRsiData = showRSI - ? (indicatorData?.rsi - ? visibleData.map(d => ({ time: d.time, rsi: d.rsi })) - : (isZoomed ? rsiData.slice(currentScrollIndex, currentScrollIndex + visibleCount) : rsiData)) + ? indicatorData?.rsi + ? visibleData.map((d) => ({ time: d.time, rsi: d.rsi })) + : isZoomed + ? rsiData.slice(currentScrollIndex, currentScrollIndex + visibleCount) + : rsiData : []; // MACD data is enriched onto processedData by mergeIndicators (aligned by ts) @@ -551,21 +814,38 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart // 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 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)); // Include Bollinger Band extremes so the overlay never clips against the Y domain - const bbHighs = showBB ? filterFinite(visibleData.map(d => d.bb_upper as number)) : []; - const bbLows = showBB ? filterFinite(visibleData.map(d => d.bb_lower as number)) : []; + const bbHighs = showBB + ? filterFinite(visibleData.map((d) => d.bb_upper as number)) + : []; + const bbLows = showBB + ? filterFinite(visibleData.map((d) => d.bb_lower as number)) + : []; - const rawMinPrice = prices.length > 0 ? Math.min(...lows.length > 0 ? lows : prices, ...prices, ...bbLows) : 0; - const rawMaxPrice = prices.length > 0 ? Math.max(...highs.length > 0 ? highs : prices, ...prices, ...bbHighs) : 100; + const rawMinPrice = + prices.length > 0 + ? Math.min(...(lows.length > 0 ? lows : prices), ...prices, ...bbLows) + : 0; + const rawMaxPrice = + prices.length > 0 + ? Math.max(...(highs.length > 0 ? highs : prices), ...prices, ...bbHighs) + : 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; + const yMin = + rawMinPrice === rawMaxPrice + ? rawMinPrice * 0.95 + : rawMinPrice > 0 + ? rawMinPrice * 0.95 + : rawMinPrice; + const yMax = + rawMinPrice === rawMaxPrice ? rawMaxPrice * 1.05 : rawMaxPrice * 1.05; // Sub-pane sizing: RSI + MACD are stacked below the main chart. The container // grows with each active pane and the main chart shrinks proportionally so the @@ -574,10 +854,8 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart const subPanePct = isZoomed ? 18 : 24; const mainChartHeight = isMaximized ? `calc(${100 - subPanePct * subPaneCount - (isZoomed ? 20 : 4)}%)` - : '340px'; - const subPaneHeight = isMaximized - ? `calc(${subPanePct}%)` - : '100px'; + : "340px"; + const subPaneHeight = isMaximized ? `calc(${subPanePct}%)` : "100px"; // Mouse-wheel zoom is now handled via non-passive native addEventListener above. // Drag-to-pan handlers (Binance-style: click + drag left/right to pan) @@ -595,30 +873,39 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart 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)); + 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' - } : {}; + 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 (
@@ -626,7 +913,7 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart

{activeTicker}

- {currentPrice !== null ? `$${currentPrice.toFixed(2)}` : '—'} + {currentPrice !== null ? `$${currentPrice.toFixed(2)}` : "—"}
@@ -634,12 +921,15 @@ export default function StockChart({ activeTicker, chartData, activeStats, chart
-
+
{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 - +
+ + Chart Type + + + +
- {/* Active markers list with delete buttons */} - {markers.length > 0 && ( -
- | Active: - {markers.map(m => ( - + + Technical Overlays + + + + + + +
+
+ {/* Maximized-only marker controls bar */} + {isMaximized && ( +
+ + Chart Markers + + + + {/* Active markers list with delete buttons */} + {markers.length > 0 && ( +
- {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 + + )} +
)} - - {/* 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" - /> -
+ 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" + /> +
-
- -
- {[ - { 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 && ( + + )} + + {/* Bollinger Bands Overlay (backend-computed via pandas-ta, aligned by ts) */} + {showBB && ( + <> + + + + + )} + + +
+ ) : ( +
- - - - - - - - - - { - 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" - /> - )} + > + {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). + +
+ )} - {/* Moving Average Indicators */} - {showSMA && ( - - )} - {showEMA && ( - - )} + {/* Zoomed date range indicator (replaces PAN CHART slider) */} + {processedData.length > 0 && isZoomed && ( +
+ + {visibleData[0]?.time} + + — + + {visibleData[visibleData.length - 1]?.time} + +
+ )} - {/* Bollinger Bands Overlay (backend-computed via pandas-ta, aligned by ts) */} - {showBB && ( - <> - 0 && ( +
+
+ + RSI (14) + + + Overbought >70 | Oversold <30 + +
+ + + + + + + {/* Baseline references */} + - - - - )} - - -
- ) : ( -
- - {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 */} - - - - - - - -
- )} + + +
+
+ )} - {/* Technical Indicator panel: MACD (12, 26, 9) — separate pane below the price chart */} - {showMACD && visibleMacdData.length > 0 && ( -
-
- MACD (12, 26, 9) - - {indicatorLoading ? 'Loading…' : 'Line · Signal · Histogram'} - -
- - - - - 0 && ( +
+
- - {/* Zero baseline */} - - - {/* Histogram (green above / red below the zero line) */} - - {visibleMacdData.map((entry, idx) => ( - = 0 ? 'rgba(16, 185, 129, 0.45)' : 'rgba(239, 68, 68, 0.45)'} /> - ))} - - - - - - -
- )} -
{/* closes chart-container */} -
{/* closes Left Column */} + > + + MACD (12, 26, 9) + + + {indicatorLoading + ? "Loading…" + : "Line · Signal · Histogram"} + +
+ + + + + + {/* Zero baseline */} + + + {/* Histogram (green above / red below the zero line) */} + + {visibleMacdData.map((entry, idx) => ( + = 0 + ? "rgba(16, 185, 129, 0.45)" + : "rgba(239, 68, 68, 0.45)" + } + /> + ))} + + + + + + +
+ )} +
{" "} + {/* closes chart-container */} +
{" "} + {/* closes Left Column */} {/* Right Column: AI Chatbot (Only rendered in maximized layout) */} {isMaximized && ( -