SoftTraderFrontend/components/candlestick-chart/CandlestickGraph.tsx
2026-03-21 09:26:43 -04:00

1474 lines
47 KiB
TypeScript

'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<ChartSize, string> = {
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<HTMLDivElement>(null);
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
// Container dimensions
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
// Theme that responds to dark/light mode
const [chartTheme, setChartTheme] = useState<ChartTheme>(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<string[]>([]);
// Active indicators with full configuration
const [activeIndicators, setActiveIndicators] = useState<ChartIndicator[]>([]);
const [indicatorsLoaded, setIndicatorsLoaded] = useState(false);
const [activeAction, setActiveAction] = useState<string | null>('crosshair');
// Draw tool state
const [selectedLineType, setSelectedLineType] = useState<string>('line');
const [selectedLineColor, setSelectedLineColor] = useState<string>('#3b82f6');
// Measure tool state
const [measurePoints, setMeasurePoints] = useState<MeasurePoint[]>([]);
// Notes state
const [notes, setNotes] = useState<ChartNote[]>([]);
// Draw lines state
const [drawLines, setDrawLines] = useState<DrawLine[]>([]);
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<ChartNote>) => {
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<ChartIndicator>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<Card
className={cn(
'flex flex-col overflow-hidden bg-chart-bg border-border',
SIZE_CLASSES[size],
className
)}
>
{/* Top Toolbar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
{/* Left: Symbol + OHLC Info or Measure Info */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="font-semibold text-foreground tracking-wide">{symbol}</span>
<span className="text-xs text-muted-foreground">
{barSize}
</span>
</div>
{/* OHLC Info Bar - shown when not measuring */}
{hoveredCandle && measurePoints.length === 0 && (
<OHLCBar candle={hoveredCandle} />
)}
{/* Measure Info Bar - shown when measuring */}
{measurePoints.length > 0 && (
<MeasureBar points={measurePoints} onClear={() => setMeasurePoints([])} />
)}
{/* Draw Info Bar - shown when drawing */}
{activeAction === 'draw' && (
<DrawBar
pendingPoint={pendingDrawPoint}
lineType={selectedLineType}
color={selectedLineColor}
onCancel={() => setPendingDrawPoint(null)}
/>
)}
</div>
{/* Right: Dropdowns */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground hover:bg-muted"
>
Indicators
<span suppressHydrationWarning className="contents">
{indicatorsLoaded && activeIndicators.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-[10px] bg-primary/20 text-primary rounded-full">
{activeIndicators.length}
</span>
)}
</span>
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-popover border-border"
>
{INDICATORS.map((indicator) => (
<DropdownMenuCheckboxItem
key={indicator.id}
checked={false}
onCheckedChange={() => {
handleAddIndicator(indicator.id as IndicatorType);
}}
className="text-sm"
>
{indicator.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground hover:bg-muted"
>
<CalendarDays className="mr-1 h-3 w-3" />
Events
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-popover border-border"
>
{EVENTS.map((event) => (
<DropdownMenuCheckboxItem
key={event.id}
checked={selectedEvents.includes(event.id)}
onCheckedChange={(checked) => {
setSelectedEvents((prev) =>
checked
? [...prev, event.id]
: prev.filter((id) => id !== event.id)
);
}}
className="text-sm"
>
{event.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Main Content Row */}
<div className="flex flex-1 min-h-0">
{/* Left Action Rail */}
<div className="flex flex-col gap-1 p-2 border-r border-border bg-muted/30 overflow-y-auto max-h-full scrollbar-thin">
{ACTION_BUTTONS.map((action) => {
const Icon = action.icon;
const isActive = activeAction === action.id;
return (
<Button
key={action.id}
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 rounded-md transition-colors',
isActive
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
onClick={() => setActiveAction(action.id)}
title={action.label}
>
<Icon className="h-4 w-4" />
</Button>
);
})}
{/* Line type options - shown when draw is selected */}
{activeAction === 'draw' && (
<>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
{LINE_TYPES.map((lineType) => {
const Icon = lineType.icon;
const isSelected = selectedLineType === lineType.id;
return (
<Button
key={lineType.id}
variant="ghost"
size="icon"
className={cn(
'h-7 w-8 rounded-md transition-colors',
isSelected
? 'bg-primary/20 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
onClick={() => setSelectedLineType(lineType.id)}
title={lineType.label}
>
<Icon className="h-3.5 w-3.5" />
</Button>
);
})}
</div>
{/* Color options */}
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
{LINE_COLORS.map((colorOption) => {
const isSelected = selectedLineColor === colorOption.color;
return (
<button
key={colorOption.id}
className={cn(
'h-6 w-8 rounded-md transition-all border-2',
isSelected
? 'border-foreground scale-110'
: 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: colorOption.color }}
onClick={() => setSelectedLineColor(colorOption.color)}
title={colorOption.label}
/>
);
})}
</div>
</>
)}
</div>
{/* Chart Area */}
<div className="relative flex-1 min-w-0">
{/* Loading State */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="flex flex-col items-center gap-2">
<Spinner className="h-6 w-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Loading chart data...</span>
</div>
</div>
)}
{/* Error State */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</div>
</div>
)}
{/* Empty State */}
{!isLoading && !error && candles.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<p className="text-sm text-muted-foreground">No data available</p>
<p className="text-xs text-muted-foreground/60 mt-1">
No candles found for {symbol}
</p>
</div>
</div>
)}
{/* Canvas Container */}
<div ref={containerRef} className="absolute inset-0">
<canvas
ref={mainCanvasRef}
className="absolute inset-0"
style={{ imageRendering: 'pixelated' }}
/>
<canvas
ref={overlayCanvasRef}
className="absolute inset-0"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
style={{ cursor: activeAction === 'note' ? 'cell' : 'crosshair' }}
/>
{/* Draw lines SVG overlay */}
<svg className="absolute inset-0 pointer-events-none z-10" style={{ overflow: 'visible' }}>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
</marker>
</defs>
{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 (
<DrawLineComponent
key={line.id}
line={line}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
onDelete={() => handleDeleteDrawLine(line.id)}
/>
);
})}
{/* Pending draw point indicator */}
{pendingDrawPoint && (
<circle
cx={getXForTime(pendingDrawPoint.time)}
cy={getYForPrice(pendingDrawPoint.price)}
r={6}
fill={selectedLineColor}
stroke="white"
strokeWidth={2}
/>
)}
</svg>
{/* 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 (
<NoteComponent
key={note.id}
note={note}
x={x}
y={y}
onUpdate={(updates) => handleUpdateNote(note.id, updates)}
onDelete={() => handleDeleteNote(note.id)}
/>
);
})}
</div>
</div>
</div>
{/* Indicator Panel - shown when indicators are active */}
{activeIndicators.length > 0 && (
<div className="border-t border-border px-4 py-2 bg-muted/20">
<div className="flex flex-wrap gap-2">
{activeIndicators.map((indicator) => (
<IndicatorPill
key={indicator.id}
indicator={indicator}
onUpdate={(updates) => handleUpdateIndicator(indicator.id, updates)}
onDelete={() => handleDeleteIndicator(indicator.id)}
/>
))}
</div>
</div>
)}
</Card>
);
}
// Indicator Pill component - editable indicator display
function IndicatorPill({
indicator,
onUpdate,
onDelete,
}: {
indicator: ChartIndicator;
onUpdate: (updates: Partial<ChartIndicator>) => 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 (
<div className="flex items-center gap-2 px-2 py-1 bg-background border border-border rounded-md text-xs">
<div
className="w-3 h-3 rounded-full cursor-pointer"
style={{ backgroundColor: indicator.color }}
onClick={() => {
const currentIndex = INDICATOR_COLORS.indexOf(indicator.color);
const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length;
onUpdate({ color: INDICATOR_COLORS[nextIndex] });
}}
title="Click to change color"
/>
<span className="font-medium text-foreground">{displayName}</span>
{/* Parameter inputs based on indicator type */}
{(indicator.type === 'sma' || indicator.type === 'ema' || indicator.type === 'rsi') && (
<Input
type="number"
value={editParams.period || ''}
onChange={(e) => setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })}
className="h-5 w-12 px-1 text-xs"
min={1}
/>
)}
{indicator.type === 'boll' && (
<>
<Input
type="number"
value={editParams.period || ''}
onChange={(e) => setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })}
className="h-5 w-12 px-1 text-xs"
min={1}
/>
<Input
type="number"
value={editParams.stdDev || ''}
onChange={(e) => 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' && (
<>
<Input
type="number"
value={editParams.fastPeriod || ''}
onChange={(e) => setEditParams({ ...editParams, fastPeriod: parseInt(e.target.value) || 1 })}
className="h-5 w-10 px-1 text-xs"
min={1}
/>
<Input
type="number"
value={editParams.slowPeriod || ''}
onChange={(e) => setEditParams({ ...editParams, slowPeriod: parseInt(e.target.value) || 1 })}
className="h-5 w-10 px-1 text-xs"
min={1}
/>
<Input
type="number"
value={editParams.signalPeriod || ''}
onChange={(e) => setEditParams({ ...editParams, signalPeriod: parseInt(e.target.value) || 1 })}
className="h-5 w-10 px-1 text-xs"
min={1}
/>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={handleSave}
>
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs text-muted-foreground"
onClick={() => {
setEditParams(indicator.params);
setIsEditing(false);
}}
>
Cancel
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded-md text-xs group">
<div
className="w-3 h-3 rounded-full cursor-pointer"
style={{ backgroundColor: indicator.color }}
onClick={() => {
const currentIndex = INDICATOR_COLORS.indexOf(indicator.color);
const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length;
onUpdate({ color: INDICATOR_COLORS[nextIndex] });
}}
title="Click to change color"
/>
<span
className="font-medium text-foreground cursor-pointer hover:underline"
onClick={() => setIsEditing(true)}
title="Click to edit parameters"
>
{displayName}
</span>
<span className="text-muted-foreground">{paramLabel}</span>
<button
className="ml-1 p-0.5 text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onDelete}
title="Remove indicator"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// 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 (
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: color }}
/>
<span className="text-muted-foreground">{lineTypeLabel}</span>
</div>
{pendingPoint ? (
<>
<span className="text-foreground">
Point 1: ${pendingPoint.price.toFixed(2)}
</span>
<span className="text-muted-foreground">
Click to set end point
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={onCancel}
>
Cancel
</Button>
</>
) : (
<span className="text-muted-foreground">
Click to set start point
</span>
)}
</div>
);
}
// Measure Bar component - displays inline in the toolbar
function MeasureBar({
points,
onClear
}: {
points: MeasurePoint[];
onClear: () => void;
}) {
if (points.length < 2) {
return (
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
<span className="text-muted-foreground">
Click to add measure points ({points.length}/2+)
</span>
{points.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={onClear}
>
Clear
</Button>
)}
</div>
);
}
// 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 (
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Points</span>
<span className="font-mono text-foreground">{points.length}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Start</span>
<span className="font-mono text-foreground">${firstPrice.toFixed(2)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">End</span>
<span className="font-mono text-foreground">${lastPrice.toFixed(2)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Change</span>
<span
className={cn(
'font-mono font-medium',
isPositive ? 'text-emerald-500' : 'text-red-500'
)}
>
{isPositive ? '+' : ''}${totalDiff.toFixed(2)} ({isPositive ? '+' : ''}{totalPercent.toFixed(2)}%)
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={onClear}
>
Clear
</Button>
</div>
);
}
// 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 (
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
<span className="text-muted-foreground">{formatTime(candle.time)}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">O</span>
<span className="font-mono text-foreground">{formatNumber(candle.open)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">H</span>
<span className="font-mono text-foreground">{formatNumber(candle.high)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">L</span>
<span className="font-mono text-foreground">{formatNumber(candle.low)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">C</span>
<span
className={cn(
'font-mono',
isBullish ? 'text-emerald-500' : 'text-red-500'
)}
>
{formatNumber(candle.close)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Vol</span>
<span className="font-mono text-foreground">{formatVolume(candle.volume)}</span>
</div>
<span
className={cn(
'font-mono font-medium',
isBullish ? 'text-emerald-500' : 'text-red-500'
)}
>
{isBullish ? '+' : ''}{changePercent.toFixed(2)}%
</span>
</div>
);
}
// 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<ChartNote>) => void;
onDelete: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [text, setText] = useState(note.text);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div
className="absolute z-20 pointer-events-auto"
style={{ left: x, top: y, transform: 'translate(-50%, -50%)' }}
>
<button
className="flex items-center justify-center h-6 w-6 rounded-full bg-yellow-500 hover:bg-yellow-400 text-yellow-950 shadow-md transition-colors"
onClick={() => onUpdate({ isMinimized: false })}
title="Expand note"
>
<StickyNote className="h-3 w-3" />
</button>
</div>
);
}
return (
<div
className="absolute z-20 pointer-events-auto"
style={{ left: x, top: y, transform: 'translate(-8px, -8px)' }}
>
<div className="bg-yellow-100 dark:bg-yellow-900/90 border border-yellow-300 dark:border-yellow-700 rounded-md shadow-lg min-w-[160px] max-w-[240px]">
{/* Header */}
<div className="flex items-center justify-between px-2 py-1 border-b border-yellow-300 dark:border-yellow-700 bg-yellow-200/50 dark:bg-yellow-800/50 rounded-t-md">
<span className="text-[10px] text-yellow-800 dark:text-yellow-200 font-medium">
${note.price.toFixed(2)}
</span>
<div className="flex items-center gap-0.5">
<button
className="p-0.5 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
onClick={() => onUpdate({ isMinimized: true })}
title="Minimize"
>
<Minimize2 className="h-3 w-3 text-yellow-700 dark:text-yellow-300" />
</button>
<button
className="p-0.5 hover:bg-red-300 dark:hover:bg-red-700 rounded transition-colors"
onClick={onDelete}
title="Delete"
>
<X className="h-3 w-3 text-yellow-700 dark:text-yellow-300" />
</button>
</div>
</div>
{/* Content */}
<div className="p-2">
{isEditing ? (
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full min-h-[60px] p-1 text-xs bg-yellow-50 dark:bg-yellow-950 border border-yellow-300 dark:border-yellow-600 rounded resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500 text-yellow-900 dark:text-yellow-100"
placeholder="Enter note..."
/>
) : (
<div
className="text-xs text-yellow-900 dark:text-yellow-100 min-h-[40px] cursor-text whitespace-pre-wrap"
onClick={() => setIsEditing(true)}
>
{note.text || (
<span className="text-yellow-600 dark:text-yellow-400 italic">
Click to add note...
</span>
)}
</div>
)}
</div>
</div>
</div>
);
}
// Draw Line component - renders a line/arrow/dotted line on the chart
function DrawLineComponent({
line,
x1,
y1,
x2,
y2,
onDelete,
}: {
line: DrawLine;
x1: number;
y1: number;
x2: number;
y2: number;
onDelete: () => void;
}) {
const [isHovered, setIsHovered] = useState(false);
const getStrokeDasharray = () => {
if (line.lineType === 'dotted') return '4,4';
return 'none';
};
const getMarkerEnd = () => {
if (line.lineType === 'arrow') return `url(#arrowhead-${line.id})`;
return undefined;
};
// Calculate midpoint for delete button
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
return (
<g
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ pointerEvents: 'auto' }}
>
{/* Arrow marker definition for this line */}
{line.lineType === 'arrow' && (
<defs>
<marker
id={`arrowhead-${line.id}`}
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill={line.color} />
</marker>
</defs>
)}
{/* Invisible wider line for easier hover */}
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="transparent"
strokeWidth={12}
style={{ cursor: 'pointer' }}
/>
{/* Visible line */}
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={line.color}
strokeWidth={2}
strokeDasharray={getStrokeDasharray()}
markerEnd={getMarkerEnd()}
style={{ cursor: 'pointer' }}
/>
{/* Start point */}
<circle
cx={x1}
cy={y1}
r={4}
fill={line.color}
stroke="white"
strokeWidth={1}
/>
{/* End point */}
<circle
cx={x2}
cy={y2}
r={4}
fill={line.color}
stroke="white"
strokeWidth={1}
/>
{/* Delete button on hover */}
{isHovered && (
<g
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
style={{ cursor: 'pointer' }}
>
<circle
cx={midX}
cy={midY}
r={10}
fill="#ef4444"
stroke="white"
strokeWidth={1}
/>
<line
x1={midX - 4}
y1={midY - 4}
x2={midX + 4}
y2={midY + 4}
stroke="white"
strokeWidth={2}
/>
<line
x1={midX + 4}
y1={midY - 4}
x2={midX - 4}
y2={midY + 4}
stroke="white"
strokeWidth={2}
/>
</g>
)}
</g>
);
}