'use client'; import React, { useRef, useEffect, useCallback, useState } from 'react'; import { cn } from '@/lib/utils'; import { Card } from '@/components/ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { ChevronDown, TrendingUp, CalendarDays, Crosshair, Ruler, PenLine, Settings, Bell, StickyNote, Minus, MoveRight, MoreHorizontal, X, Minimize2, Maximize2, Trash2, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import type { CandlestickGraphProps, Candle, ChartTheme, ChartSize, MeasurePoint, ChartNote, DrawLine, ChartAnnotations, ChartIndicator, IndicatorType } from './chartTypes'; import { DEFAULT_INDICATOR_PARAMS, INDICATOR_COLORS } from './chartTypes'; import { getIndicatorDisplayName, getIndicatorParamLabel } from './indicatorUtils'; import { useCandlestickData } from './useCandlestickData'; import { useCandlestickViewModel } from './useCandlestickViewModel'; import { renderChart, renderCrosshair, renderMeasure } from './chartRenderer'; import { DEFAULT_CHART_THEME, getChartTheme } from './chartTypes'; const ANNOTATIONS_API_BASE = 'https://api.dongfeng-systems.org'; // Chart size presets const SIZE_CLASSES: Record = { sm: 'h-[300px]', md: 'h-[400px]', lg: 'h-[500px]', xl: 'h-[600px]', full: 'h-full', }; // Indicator options const INDICATORS = [ { id: 'sma', label: 'SMA (Simple Moving Average)' }, { id: 'ema', label: 'EMA (Exponential Moving Average)' }, { id: 'boll', label: 'Bollinger Bands' }, { id: 'rsi', label: 'RSI' }, { id: 'macd', label: 'MACD' }, { id: 'volume', label: 'Volume Profile' }, ]; // Event options const EVENTS = [ { id: 'earnings', label: 'Earnings Reports' }, { id: 'dividends', label: 'Dividend Dates' }, { id: 'splits', label: 'Stock Splits' }, { id: 'news', label: 'Major News' }, ]; // Action rail buttons const ACTION_BUTTONS = [ { id: 'crosshair', icon: Crosshair, label: 'Crosshair' }, { id: 'measure', icon: Ruler, label: 'Measure' }, { id: 'draw', icon: PenLine, label: 'Draw' }, { id: 'note', icon: StickyNote, label: 'Insert Note' }, { id: 'alert', icon: Bell, label: 'Alerts' }, { id: 'settings', icon: Settings, label: 'Settings' }, ]; // Draw line type options const LINE_TYPES = [ { id: 'line', icon: Minus, label: 'Line' }, { id: 'arrow', icon: MoveRight, label: 'Arrow' }, { id: 'dotted', icon: MoreHorizontal, label: 'Dotted Line' }, ]; // Draw color options const LINE_COLORS = [ { id: 'red', color: '#ef4444', label: 'Red' }, { id: 'green', color: '#22c55e', label: 'Green' }, { id: 'blue', color: '#3b82f6', label: 'Blue' }, { id: 'yellow', color: '#eab308', label: 'Yellow' }, ]; export function CandlestickGraph({ symbol, user, startDate, endDate, barSize, size = 'lg', className, }: CandlestickGraphProps) { // Refs for canvas elements const containerRef = useRef(null); const mainCanvasRef = useRef(null); const overlayCanvasRef = useRef(null); const rafRef = useRef(null); // Container dimensions const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); // Theme that responds to dark/light mode const [chartTheme, setChartTheme] = useState(DEFAULT_CHART_THEME); // Mouse state (using ref to avoid re-renders) const mouseStateRef = useRef({ x: 0, y: 0, isInChart: false }); // Dropdown states const [selectedEvents, setSelectedEvents] = useState([]); // Active indicators with full configuration const [activeIndicators, setActiveIndicators] = useState([]); const [indicatorsLoaded, setIndicatorsLoaded] = useState(false); const [activeAction, setActiveAction] = useState('crosshair'); // Draw tool state const [selectedLineType, setSelectedLineType] = useState('line'); const [selectedLineColor, setSelectedLineColor] = useState('#3b82f6'); // Measure tool state const [measurePoints, setMeasurePoints] = useState([]); // Notes state const [notes, setNotes] = useState([]); // Draw lines state const [drawLines, setDrawLines] = useState([]); const [pendingDrawPoint, setPendingDrawPoint] = useState<{ time: number; price: number } | null>(null); // Load indicators from localStorage on mount (client-side only) useEffect(() => { try { const stored = localStorage.getItem('chart-indicators'); if (stored) { setActiveIndicators(JSON.parse(stored)); } } catch { // localStorage might be unavailable } setIndicatorsLoaded(true); }, []); // Save indicators to localStorage when they change (shared across all symbols) useEffect(() => { if (!indicatorsLoaded) return; // Don't save on initial load try { localStorage.setItem('chart-indicators', JSON.stringify(activeIndicators)); } catch { // localStorage might be full or unavailable } }, [activeIndicators, indicatorsLoaded]); // Fetch annotations on mount useEffect(() => { const fetchAnnotations = async () => { try { const response = await fetch( `${ANNOTATIONS_API_BASE}/api/annotations/fetch?user=${encodeURIComponent(user)}&symbol=${encodeURIComponent(symbol)}` ); if (response.ok) { const data: ChartAnnotations = await response.json(); if (data.notes && Array.isArray(data.notes)) { setNotes(data.notes); } if (data.drawLines && Array.isArray(data.drawLines)) { setDrawLines(data.drawLines); } } } catch (err) { console.error('Failed to fetch annotations:', err); } }; fetchAnnotations(); }, [user, symbol]); // Save annotations const saveAnnotations = useCallback(async (updatedNotes: ChartNote[], updatedDrawLines: DrawLine[]) => { try { const annotations: ChartAnnotations = { notes: updatedNotes, drawLines: updatedDrawLines, }; await fetch( `${ANNOTATIONS_API_BASE}/api/annotations/save?user=${encodeURIComponent(user)}&symbol=${encodeURIComponent(symbol)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(annotations), } ); } catch (err) { console.error('Failed to save annotations:', err); } }, [user, symbol]); // Note handlers const handleAddNote = useCallback((time: number, price: number) => { const newNote: ChartNote = { id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, time, price, text: '', isMinimized: false, createdAt: Date.now(), }; const updatedNotes = [...notes, newNote]; setNotes(updatedNotes); saveAnnotations(updatedNotes, drawLines); }, [notes, drawLines, saveAnnotations]); const handleUpdateNote = useCallback((id: string, updates: Partial) => { const updatedNotes = notes.map(note => note.id === id ? { ...note, ...updates } : note ); setNotes(updatedNotes); saveAnnotations(updatedNotes, drawLines); }, [notes, drawLines, saveAnnotations]); const handleDeleteNote = useCallback((id: string) => { const updatedNotes = notes.filter(note => note.id !== id); setNotes(updatedNotes); saveAnnotations(updatedNotes, drawLines); }, [notes, drawLines, saveAnnotations]); // Draw line handlers const handleAddDrawLine = useCallback((startTime: number, startPrice: number, endTime: number, endPrice: number) => { const newLine: DrawLine = { id: `draw-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, startTime, startPrice, endTime, endPrice, lineType: selectedLineType as 'line' | 'arrow' | 'dotted', color: selectedLineColor, createdAt: Date.now(), }; const updatedDrawLines = [...drawLines, newLine]; setDrawLines(updatedDrawLines); saveAnnotations(notes, updatedDrawLines); }, [drawLines, notes, selectedLineType, selectedLineColor, saveAnnotations]); const handleDeleteDrawLine = useCallback((id: string) => { const updatedDrawLines = drawLines.filter(line => line.id !== id); setDrawLines(updatedDrawLines); saveAnnotations(notes, updatedDrawLines); }, [drawLines, notes, saveAnnotations]); // Indicator handlers const handleAddIndicator = useCallback((type: IndicatorType) => { const colorIndex = activeIndicators.length % INDICATOR_COLORS.length; const newIndicator: ChartIndicator = { id: `ind-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type, params: { ...DEFAULT_INDICATOR_PARAMS[type] }, color: INDICATOR_COLORS[colorIndex], enabled: true, }; setActiveIndicators(prev => [...prev, newIndicator]); }, [activeIndicators.length]); const handleUpdateIndicator = useCallback((id: string, updates: Partial) => { setActiveIndicators(prev => prev.map(ind => ind.id === id ? { ...ind, ...updates } : ind) ); }, []); const handleDeleteIndicator = useCallback((id: string) => { setActiveIndicators(prev => prev.filter(ind => ind.id !== id)); }, []); // Data layer const { candles, visibleStartOffset, isLoading, error } = useCandlestickData({ symbol, user, startDate, endDate, barSize, }); // View model layer const { viewModel, setHoveredIndex, getXForIndex, getYForPrice, getYForVolume, getIndexForX, getPriceForY, getTimeForX, getXForTime, } = useCandlestickViewModel({ candles, containerWidth: dimensions.width, containerHeight: dimensions.height, visibleStartOffset, }); // Handle resize useEffect(() => { const container = containerRef.current; if (!container) return; const resizeObserver = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { const { width, height } = entry.contentRect; setDimensions({ width, height }); } }); resizeObserver.observe(container); // Initial measurement const rect = container.getBoundingClientRect(); setDimensions({ width: rect.width, height: rect.height }); return () => resizeObserver.disconnect(); }, []); // Observe dark/light mode changes useEffect(() => { const updateTheme = () => { setChartTheme(getChartTheme()); }; // Initial theme updateTheme(); // Watch for class changes on document element (dark mode toggle) const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.attributeName === 'class') { updateTheme(); } } }); observer.observe(document.documentElement, { attributes: true }); // Also listen for media query changes const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); mediaQuery.addEventListener('change', updateTheme); return () => { observer.disconnect(); mediaQuery.removeEventListener('change', updateTheme); }; }, []); // Set up canvas DPR useEffect(() => { const mainCanvas = mainCanvasRef.current; const overlayCanvas = overlayCanvasRef.current; if (!mainCanvas || !overlayCanvas) return; const dpr = window.devicePixelRatio || 1; const { width, height } = dimensions; // Size canvases mainCanvas.width = width * dpr; mainCanvas.height = height * dpr; mainCanvas.style.width = `${width}px`; mainCanvas.style.height = `${height}px`; overlayCanvas.width = width * dpr; overlayCanvas.height = height * dpr; overlayCanvas.style.width = `${width}px`; overlayCanvas.style.height = `${height}px`; }, [dimensions]); // Main render loop const render = useCallback(() => { const mainCanvas = mainCanvasRef.current; const overlayCanvas = overlayCanvasRef.current; if (!mainCanvas || !overlayCanvas) return; const mainCtx = mainCanvas.getContext('2d'); const overlayCtx = overlayCanvas.getContext('2d'); if (!mainCtx || !overlayCtx) return; const dpr = window.devicePixelRatio || 1; // Render main chart renderChart( mainCtx, viewModel, chartTheme, dpr, getXForIndex, getYForPrice, getYForVolume, activeIndicators ); // Clear overlay canvas overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); const { x, y, isInChart } = mouseStateRef.current; // Always render measure points if they exist (persistent across tool switches) if (measurePoints.length > 0) { renderMeasure( overlayCtx, viewModel, measurePoints, activeAction === 'measure' && isInChart ? { x, y } : null, chartTheme, dpr, getYForPrice ); } // Render crosshair overlay when not in measure mode if (isInChart && activeAction !== 'measure') { renderCrosshair( overlayCtx, viewModel, x, y, chartTheme, dpr, getXForIndex, getYForPrice ); } }, [viewModel, chartTheme, activeAction, measurePoints, getXForIndex, getYForPrice, getYForVolume, activeIndicators]); // Schedule render with RAF const scheduleRender = useCallback(() => { if (rafRef.current !== null) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = null; render(); }); }, [render]); // Render when view model changes useEffect(() => { scheduleRender(); }, [scheduleRender, viewModel]); // Mouse handlers const handleMouseMove = useCallback( (e: React.MouseEvent) => { const canvas = overlayCanvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; mouseStateRef.current = { x, y, isInChart: true }; // Find nearest candle const index = getIndexForX(x); setHoveredIndex(index); // Re-render overlay scheduleRender(); }, [getIndexForX, setHoveredIndex, scheduleRender] ); const handleMouseLeave = useCallback(() => { mouseStateRef.current = { ...mouseStateRef.current, isInChart: false }; setHoveredIndex(null); scheduleRender(); }, [setHoveredIndex, scheduleRender]); // Handle click for measure tool and note insertion const handleClick = useCallback( (e: React.MouseEvent) => { const canvas = overlayCanvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Get price and time for this point const price = getPriceForY(y); const index = getIndexForX(x); if (index === null || index < 0 || index >= candles.length) return; const candle = candles[index]; if (activeAction === 'measure') { const newPoint: MeasurePoint = { x, y, price, time: candle.time, index, }; setMeasurePoints((prev) => [...prev, newPoint]); scheduleRender(); } else if (activeAction === 'note') { handleAddNote(candle.time, price); } else if (activeAction === 'draw') { // For draw, use precise time based on x position (not snapped to candle) const preciseTime = getTimeForX(x); if (preciseTime === null) return; if (pendingDrawPoint === null) { // First point - store it with precise time setPendingDrawPoint({ time: preciseTime, price }); } else { // Second point - create the line with precise times handleAddDrawLine(pendingDrawPoint.time, pendingDrawPoint.price, preciseTime, price); setPendingDrawPoint(null); } } }, [activeAction, getPriceForY, getIndexForX, getTimeForX, candles, scheduleRender, handleAddNote, pendingDrawPoint, handleAddDrawLine] ); // Clear pending draw point when switching away from draw tool useEffect(() => { if (activeAction !== 'draw') { setPendingDrawPoint(null); } }, [activeAction]); // Get hovered candle for tooltip const hoveredCandle = viewModel.hoveredIndex !== null && viewModel.hoveredIndex < candles.length ? candles[viewModel.hoveredIndex] : null; return ( {/* Top Toolbar */}
{/* Left: Symbol + OHLC Info or Measure Info */}
{symbol} {barSize}
{/* OHLC Info Bar - shown when not measuring */} {hoveredCandle && measurePoints.length === 0 && ( )} {/* Measure Info Bar - shown when measuring */} {measurePoints.length > 0 && ( setMeasurePoints([])} /> )} {/* Draw Info Bar - shown when drawing */} {activeAction === 'draw' && ( setPendingDrawPoint(null)} /> )}
{/* Right: Dropdowns */}
{INDICATORS.map((indicator) => ( { handleAddIndicator(indicator.id as IndicatorType); }} className="text-sm" > {indicator.label} ))} {EVENTS.map((event) => ( { setSelectedEvents((prev) => checked ? [...prev, event.id] : prev.filter((id) => id !== event.id) ); }} className="text-sm" > {event.label} ))}
{/* Main Content Row */}
{/* Left Action Rail */}
{ACTION_BUTTONS.map((action) => { const Icon = action.icon; const isActive = activeAction === action.id; return ( ); })} {/* Line type options - shown when draw is selected */} {activeAction === 'draw' && ( <>
{LINE_TYPES.map((lineType) => { const Icon = lineType.icon; const isSelected = selectedLineType === lineType.id; return ( ); })}
{/* Color options */}
{LINE_COLORS.map((colorOption) => { const isSelected = selectedLineColor === colorOption.color; return (
)}
{/* Chart Area */}
{/* Loading State */} {isLoading && (
Loading chart data...
)} {/* Error State */} {error && (

Failed to load chart data

{error.message}

)} {/* Empty State */} {!isLoading && !error && candles.length === 0 && (

No data available

No candles found for {symbol}

)} {/* Canvas Container */}
{/* Draw lines SVG overlay */} {drawLines.map((line) => { // Use precise time-based x coordinates const x1 = getXForTime(line.startTime); const y1 = getYForPrice(line.startPrice); const x2 = getXForTime(line.endTime); const y2 = getYForPrice(line.endPrice); return ( handleDeleteDrawLine(line.id)} /> ); })} {/* Pending draw point indicator */} {pendingDrawPoint && ( )} {/* Notes overlay */} {notes.map((note) => { // Check if note is within the visible time range if (candles.length === 0) return null; const startCandle = candles[viewModel.visibleStartIndex]; const endCandle = candles[viewModel.visibleEndIndex]; if (!startCandle || !endCandle) return null; if (note.time < startCandle.time || note.time > endCandle.time) return null; // Use interpolated time positioning (works across all bar sizes) const x = getXForTime(note.time); const y = getYForPrice(note.price); return ( handleUpdateNote(note.id, updates)} onDelete={() => handleDeleteNote(note.id)} /> ); })}
{/* Indicator Panel - shown when indicators are active */} {activeIndicators.length > 0 && (
{activeIndicators.map((indicator) => ( handleUpdateIndicator(indicator.id, updates)} onDelete={() => handleDeleteIndicator(indicator.id)} /> ))}
)}
); } // Indicator Pill component - editable indicator display function IndicatorPill({ indicator, onUpdate, onDelete, }: { indicator: ChartIndicator; onUpdate: (updates: Partial) => void; onDelete: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editParams, setEditParams] = useState(indicator.params); const handleSave = () => { onUpdate({ params: editParams }); setIsEditing(false); }; const displayName = getIndicatorDisplayName(indicator.type); const paramLabel = getIndicatorParamLabel(indicator); if (isEditing) { return (
{ const currentIndex = INDICATOR_COLORS.indexOf(indicator.color); const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length; onUpdate({ color: INDICATOR_COLORS[nextIndex] }); }} title="Click to change color" /> {displayName} {/* Parameter inputs based on indicator type */} {(indicator.type === 'sma' || indicator.type === 'ema' || indicator.type === 'rsi') && ( setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })} className="h-5 w-12 px-1 text-xs" min={1} /> )} {indicator.type === 'boll' && ( <> setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })} className="h-5 w-12 px-1 text-xs" min={1} /> setEditParams({ ...editParams, stdDev: parseFloat(e.target.value) || 1 })} className="h-5 w-10 px-1 text-xs" min={0.1} step={0.1} /> )} {indicator.type === 'macd' && ( <> setEditParams({ ...editParams, fastPeriod: parseInt(e.target.value) || 1 })} className="h-5 w-10 px-1 text-xs" min={1} /> setEditParams({ ...editParams, slowPeriod: parseInt(e.target.value) || 1 })} className="h-5 w-10 px-1 text-xs" min={1} /> setEditParams({ ...editParams, signalPeriod: parseInt(e.target.value) || 1 })} className="h-5 w-10 px-1 text-xs" min={1} /> )}
); } return (
{ const currentIndex = INDICATOR_COLORS.indexOf(indicator.color); const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length; onUpdate({ color: INDICATOR_COLORS[nextIndex] }); }} title="Click to change color" /> setIsEditing(true)} title="Click to edit parameters" > {displayName} {paramLabel}
); } // Draw Bar component - displays inline in the toolbar when drawing function DrawBar({ pendingPoint, lineType, color, onCancel }: { pendingPoint: { time: number; price: number } | null; lineType: string; color: string; onCancel: () => void; }) { const lineTypeLabel = lineType === 'line' ? 'Line' : lineType === 'arrow' ? 'Arrow' : 'Dotted'; return (
{lineTypeLabel}
{pendingPoint ? ( <> Point 1: ${pendingPoint.price.toFixed(2)} Click to set end point ) : ( Click to set start point )}
); } // Measure Bar component - displays inline in the toolbar function MeasureBar({ points, onClear }: { points: MeasurePoint[]; onClear: () => void; }) { if (points.length < 2) { return (
Click to add measure points ({points.length}/2+) {points.length > 0 && ( )}
); } // Calculate total difference from first to last point const firstPrice = points[0].price; const lastPrice = points[points.length - 1].price; const totalDiff = lastPrice - firstPrice; const totalPercent = (totalDiff / firstPrice) * 100; const isPositive = totalDiff >= 0; return (
Points {points.length}
Start ${firstPrice.toFixed(2)}
End ${lastPrice.toFixed(2)}
Change {isPositive ? '+' : ''}${totalDiff.toFixed(2)} ({isPositive ? '+' : ''}{totalPercent.toFixed(2)}%)
); } // OHLC Bar component - displays inline in the toolbar function OHLCBar({ candle }: { candle: Candle }) { const isBullish = candle.close >= candle.open; const changePercent = ((candle.close - candle.open) / candle.open) * 100; const formatTime = (timestamp: number) => { const date = new Date(timestamp); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, }); }; const formatNumber = (num: number) => { return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); }; const formatVolume = (num: number) => { if (num >= 1000000) return (num / 1000000).toFixed(2) + 'M'; if (num >= 1000) return (num / 1000).toFixed(2) + 'K'; return num.toString(); }; return (
{formatTime(candle.time)}
O {formatNumber(candle.open)}
H {formatNumber(candle.high)}
L {formatNumber(candle.low)}
C {formatNumber(candle.close)}
Vol {formatVolume(candle.volume)}
{isBullish ? '+' : ''}{changePercent.toFixed(2)}%
); } // Note component - floating editable text box on the chart function NoteComponent({ note, x, y, onUpdate, onDelete, }: { note: ChartNote; x: number; y: number; onUpdate: (updates: Partial) => void; onDelete: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [text, setText] = useState(note.text); const textareaRef = useRef(null); useEffect(() => { if (isEditing && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.select(); } }, [isEditing]); const handleSave = () => { onUpdate({ text }); setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } if (e.key === 'Escape') { setText(note.text); setIsEditing(false); } }; if (note.isMinimized) { return (
); } return (
{/* Header */}
${note.price.toFixed(2)}
{/* Content */}
{isEditing ? (